From 11005df1ce83b62980344a3a483c39a1674ba05e Mon Sep 17 00:00:00 2001 From: harlan Date: Thu, 9 Jan 2025 18:31:40 +1100 Subject: [PATCH] feat: support event deduping --- packages/scripts/src/types.ts | 13 ++++-- packages/scripts/src/useScript.ts | 19 ++++++--- packages/scripts/test/unit/events.test.ts | 50 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 packages/scripts/test/unit/events.test.ts diff --git a/packages/scripts/src/types.ts b/packages/scripts/src/types.ts index 0ceb3614..ee2285fb 100644 --- a/packages/scripts/src/types.ts +++ b/packages/scripts/src/types.ts @@ -28,15 +28,15 @@ export interface ScriptInstance { proxy: AsVoidFunctions instance?: T id: string - status: UseScriptStatus + status: Readonly entry?: ActiveHeadEntry load: () => Promise warmup: (rel: WarmupStrategy) => ActiveHeadEntry remove: () => boolean setupTriggerHandler: (trigger: UseScriptOptions['trigger']) => void // cbs - onLoaded: (fn: (instance: T) => void | Promise) => void - onError: (fn: (err?: Error) => void | Promise) => void + onLoaded: (fn: (instance: T) => void | Promise, options?: EventHandlerOptions) => void + onError: (fn: (err?: Error) => void | Promise, options?: EventHandlerOptions) => void /** * @internal */ @@ -66,6 +66,13 @@ export interface ScriptInstance { } } +export interface EventHandlerOptions { + /** + * Used to dedupe the event, allowing you to have an event run only a single time. + */ + key?: string +} + export type RecordingEntry = | { type: 'get', key: string | symbol, args?: any[], value?: any } | { type: 'apply', key: string | symbol, args: any[] } diff --git a/packages/scripts/src/useScript.ts b/packages/scripts/src/useScript.ts index 7cac2413..25dee956 100644 --- a/packages/scripts/src/useScript.ts +++ b/packages/scripts/src/useScript.ts @@ -2,6 +2,7 @@ import type { Head, } from '@unhead/schema' import type { + EventHandlerOptions, ScriptInstance, UseFunctionType, UseScriptContext, @@ -52,7 +53,15 @@ export function useScript = Record['_cbs'] = { loaded: [], error: [] } - const _registerCb = (key: 'loaded' | 'error', cb: any) => { + const _uniqueCbs: Set = new Set() + const _registerCb = (key: 'loaded' | 'error', cb: any, options?: EventHandlerOptions) => { + if (options?.key) { + const key = `${options?.key}:${options.key}` + if (_uniqueCbs.has(key)) { + return + } + _uniqueCbs.add(key) + } if (_cbs[key]) { const i: number = _cbs[key].push(cb) return () => _cbs[key]?.splice(i - 1, 1) @@ -160,11 +169,11 @@ export function useScript = Record void | Promise) { - return _registerCb('loaded', cb) + onLoaded(cb: (instance: T) => void | Promise, options?: EventHandlerOptions) { + return _registerCb('loaded', cb, options) }, - onError(cb: (err?: Error) => void | Promise) { - return _registerCb('error', cb) + onError(cb: (err?: Error) => void | Promise, options?: EventHandlerOptions) { + return _registerCb('error', cb, options) }, setupTriggerHandler(trigger: UseScriptOptions['trigger']) { if (script.status !== 'awaitingLoad') { diff --git a/packages/scripts/test/unit/events.test.ts b/packages/scripts/test/unit/events.test.ts new file mode 100644 index 00000000..c40ed5b1 --- /dev/null +++ b/packages/scripts/test/unit/events.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { describe, it } from 'vitest' +import { createHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' + +describe('useScript events', () => { + it('simple', async () => { + createHeadWithContext() + const instance = useScript('/script.js', { + trigger: 'server', + }) + expect(await new Promise((resolve) => { + instance.status = 'loaded' + instance.onLoaded(() => { + resolve(true) + }) + })).toBeTruthy() + }) + it('dedupe', async () => { + createHeadWithContext() + const instance = useScript('/script.js', { + trigger: 'server', + }) + const calls: any[] = [] + instance.onLoaded(() => { + calls.push('a') + }, { + key: 'once', + }) + instance.onLoaded(() => { + calls.push('b') + }, { + key: 'once', + }) + instance.status = 'loaded' + await new Promise((resolve) => { + instance.onLoaded(() => { + calls.push('c') + resolve() + }) + }) + expect(calls).toMatchInlineSnapshot(` + [ + "a", + "c", + ] + `) + }) +})