diff --git a/.changeset/fair-moles-sin.md b/.changeset/fair-moles-sin.md new file mode 100644 index 000000000..cb1367efc --- /dev/null +++ b/.changeset/fair-moles-sin.md @@ -0,0 +1,5 @@ +--- +"@preact/signals": minor +--- + +Add support for delaying effect execution to the next frame diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 5171394ae..8a51e3b45 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -49,6 +49,17 @@ function createUpdater(update: () => void) { return updater; } +function createFlusher(compute: () => unknown, notify: () => void) { + let flush!: () => void; + const dispose = effect(function (this: Effect) { + flush = this._callback.bind(this); + this._compute = compute; + this._callback = notify; + return compute(); + }); + return { flush, dispose }; +} + /** @todo This may be needed for complex prop value detection. */ // function isSignalValue(value: any): value is Signal { // if (typeof value !== "object" || value == null) return false; @@ -342,12 +353,50 @@ export function useComputed(compute: () => T) { return useMemo(() => computed(() => $compute.current()), []); } -export function useSignalEffect(cb: () => void | (() => void)) { +let HAS_RAF = typeof requestAnimationFrame == 'function'; + +/** + * Schedule a callback to be invoked after the browser has a chance to paint a new frame. + * Do this by combining requestAnimationFrame (rAF) + setTimeout to invoke a callback after + * the next browser frame. + * + * Also, schedule a timeout in parallel to the the rAF to ensure the callback is invoked + * even if RAF doesn't fire (for example if the browser tab is not visible) + */ + function afterNextFrame(callback: () => void) { + const done = () => { + clearTimeout(timeout); + if (HAS_RAF) cancelAnimationFrame(raf); + setTimeout(callback); + }; + const timeout = setTimeout(done, 100); + + let raf: number; + if (HAS_RAF) { + raf = requestAnimationFrame(done); + } +} + +type EffectOptions = { + mode: 'afterPaint' | 'sync' +} + +export function useSignalEffect(cb: () => void | (() => void), options?: EffectOptions) { const callback = useRef(cb); callback.current = cb; useEffect(() => { - return effect(() => callback.current()); + const mode = options?.mode || 'sync' + const execute = () => callback.current(); + const notify = () => { + if (mode === 'afterPaint') { + afterNextFrame(eff.flush) + } else { + eff.flush(); + } + } + const eff = createFlusher(execute, notify); + return eff.dispose }, []); } diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts index 436ac029a..1d886808c 100644 --- a/packages/preact/src/internal.d.ts +++ b/packages/preact/src/internal.d.ts @@ -4,6 +4,7 @@ import { Signal } from "@preact/signals-core"; export interface Effect { _sources: object | undefined; _start(): () => void; + _compute(): void; _callback(): void; _dispose(): void; }