import { useEffect, useRef, useState } from "react"; export type CallbackGroup = { /** * add callback into the group and return `remove` function * @param callback */ add(callback: Function): VoidFunction; called(): number; /** * call all callbacks with specified args * @param args */ call(...args: any[]): void; /** * remove all callbacks */ clear(): void; size(): number; }; export type Payload = | T | Promise | ((context: EffectContext) => T | Promise); export type Signal = { (payload: Payload): void; (payload: Payload, noDispose: boolean): void; key: string | undefined; payload(): TDefaultPayload; onEmit(listener: VoidFunction): VoidFunction; onLoading(listener: (promise: Promise) => void): VoidFunction; onError(listener: (error: any) => void): VoidFunction; error(): any; cancel(): void; async(): Promise | undefined; cancelled(): boolean; reset(): void; reset(removeAllListeners: boolean): void; }; export type CreateSignal = { (): Signal; (options: SignalOptionsWithPayload): Signal; (options: SignalOptions): Signal; }; export type SignalList = Signal | (Signal | SignalList)[]; export type SignalOptions = { key?: string; onError?: (error: any) => void; onCancel?: VoidFunction; }; export type SignalOptionsWithPayload = SignalOptions & { payload: T }; export type UseSignalOptions = { suspense?: boolean; errorBoundary?: boolean; }; export type AnySignal = Signal; export type ChainOptions = {}; export type WaitOptions = { onError?: (...signal: AnySignal[]) => void; onDone?: (...signal: AnySignal[]) => void; }; export type EffectContext = { promise?: CancellablePromise; abortController(): AbortController | undefined; /** * cancel current execution */ cancel(): void; /** * */ cancelled(): boolean; /** * * @param listener */ onCancel(listener: VoidFunction): VoidFunction; /** * */ dispose(): void; /** * * @param promise */ when(promise: Promise): Promise; /** * * @param signal * @param options */ when(signal: Signal, options?: WaitOptions): Promise; /** * * @param signals * @param options */ all>>( signals: T, options?: WaitOptions ): Promise<{ [key in keyof T]: T[key] extends Promise ? Signal : T[key]; }>; /** * * @param signal * @param callback */ on( signal: T, callback: (signal: T) => void ): VoidFunction; /** * * @param signals * @param callback */ on(signals: SignalList, callback: (signal: AnySignal) => void): VoidFunction; /** * * @param signals * @param options */ race>>( signals: T, options?: WaitOptions ): Promise< Partial<{ [key in keyof T]: T[key] extends Promise ? Signal : T[key]; }> >; /** * * @param fn * @param args */ call( fn: (context: EffectContext, ...args: A) => R, ...args: A ): R; /** * create a promise that awaits signal chain * @param signals */ chain(signals: ChainItem[]): Promise; /** * * @param effect * @param args */ fork( effect: ( context: EffectContext, ...args: A ) => Exclude, Function>, ...args: A ): Signal; /** * * @param payload */ fork(payload: Exclude, Function>): Signal; }; export type CancellablePromise = Promise & { cancel(): void }; export type NoInfer = [T][T extends any ? 0 : never]; export type ChainItemResult = AnySignal | SignalList | void | "restart"; export type ChainItem = | AnySignal | SignalList | ((prev: AnySignal) => ChainItemResult | Promise); const signalType = {}; export const isSignal = (value: any): value is Signal => { return value && value.$$type === signalType; }; export const isPromiseLike = (value: any): value is Promise => { return value && typeof value.then === "function"; }; const createCallbackGroup = (): CallbackGroup => { const callbacks: Function[] = []; let called = 0; return { size: () => callbacks.length, called: () => called, add(callback: Function) { callbacks.push(callback); let active = true; return () => { if (!active) return; active = false; const index = callbacks.indexOf(callback); if (index !== -1) callbacks.splice(index, 1); }; }, clear() { callbacks.length = 0; }, call(...args: any[]) { // optimize performance if (args.length > 2) { callbacks.slice().forEach((callback) => callback(...args)); } else if (args.length === 2) { callbacks.slice().forEach((callback) => callback(args[0], args[1])); } else if (args.length === 1) { callbacks.slice().forEach((callback) => callback(args[0])); } else { callbacks.slice().forEach((callback) => callback()); } }, }; }; const forEachSignal = ( signals: SignalList, callback: (signal: AnySignal) => void ) => { if (Array.isArray(signals)) { signals.forEach((x) => forEachSignal(x, callback)); } else { callback(signals); } }; export const delay = (ms: number = 0, value?: T): CancellablePromise => { let timer: any; return Object.assign( new Promise((resolve) => (timer = setTimeout(resolve, ms, value))), { cancel: () => clearTimeout(timer) } ); }; const isAbortControllerSupported = typeof AbortController !== "undefined"; const createSignalContext = (): EffectContext => { let cancelled = false; let abortController: AbortController | undefined; const onCancel = createCallbackGroup(); const onDispose = createCallbackGroup(); const wait = (race: boolean, awaitables: any, options?: WaitOptions) => { return new Promise((resolve, reject) => { let doneCount = 0; let done = false; const cleanup = createCallbackGroup(); const signalList: AnySignal[] = []; const result: Record = {}; // remove onDispose listener cleanup.add( // remove all signal listeners when context is disposed onDispose.add(cleanup.call) ); const onDone = (key: string, signal: AnySignal) => { if (done) return; const error = signal.error(); if (error) { options?.onDone?.(...signalList); options?.onError?.(...signalList); cleanup.call(); return reject(error); } result[key] = signal; doneCount++; if (race || doneCount === signalList.length) { done = true; cleanup.call(); options?.onDone?.(...signalList); resolve(result); } }; Object.keys(awaitables).forEach((key) => { const awaitable = awaitables[key]; let s: AnySignal; if (isPromiseLike(awaitable)) { s = signal(); s(awaitable); } else { s = awaitable; } signalList.push(s); cleanup.add(s.onEmit(() => onDone(key, s))); cleanup.add(s.onError(() => onDone(key, s))); }); }); }; const context: EffectContext = { abortController() { if (isAbortControllerSupported && !abortController) { abortController = new AbortController(); } return abortController; }, cancel() { if (cancelled) return; cancelled = true; abortController?.abort(); context.promise?.cancel(); onCancel.call(); onDispose.call(); }, cancelled() { return cancelled; }, onCancel(listener) { return onCancel.add(listener); }, all(signals, options) { return wait(false, signals, options); }, on(singals: SignalList, onEmit: Function) { const cleanup = createCallbackGroup(); cleanup.add(onDispose.add(cleanup.call)); forEachSignal(singals, (signal) => { signal.onEmit(() => onEmit(signal)); }); return cleanup.call; }, when(awaitable, options?) { let s: AnySignal; if (isPromiseLike(awaitable)) { s = signal() as AnySignal; s(awaitable); awaitable = s; } else { s = awaitable; } return wait(true, { awaitable }, options as WaitOptions).then(() => s.payload() ) as any; }, race(signals, options) { return wait(true, signals, options); }, call(fn, ...args) { return fn(context, ...args); }, fork(payload: Payload, ...args: any[]) { const newSignal = signal(); onDispose.add(newSignal.cancel); newSignal( typeof payload === "function" ? (context: EffectContext) => (payload as Function)(context, ...args) : payload ); return newSignal; }, chain(inputEntries) { return new Promise((resolve, reject) => { let cleanup: CallbackGroup | undefined; let entries = inputEntries.slice(); const handleError = (error: any) => { cleanup?.call(); reject(error); }; const handleNext = async (prevSignal?: AnySignal) => { cleanup?.call(); if (!entries.length) { resolve(prevSignal as AnySignal); return; } const entry = entries.shift(); const signals = typeof entry === "function" && !isSignal(entry) ? await entry(prevSignal as AnySignal) : entry; if (signals === "restart") { entries = inputEntries.slice(); handleNext(); return; } if (!signals) { resolve(prevSignal as AnySignal); return; } cleanup = createCallbackGroup(); // remove onDispose listener cleanup.add( // remove all listeners when context is disposed onDispose.add(cleanup.call) ); forEachSignal(signals as SignalList, (signal) => { cleanup?.add(signal.onEmit(() => handleNext(signal))); cleanup?.add(signal.onError(handleError)); }); }; handleNext(); }); }, dispose() { onDispose.call(); }, }; return context; }; /** * * @param payload * @param args * @returns */ export const spawn = ( payload: ( context: EffectContext, ...args: A ) => Exclude, Function>, ...args: A ): Signal => { const newSignal = signal(); newSignal((context) => payload(context, ...args), true); return newSignal; }; export const cancelAll = (...signals: AnySignal[]) => { signals.forEach((signal) => { signal.cancel(); }); }; const noop = () => {}; export const signal: CreateSignal = ( options: SignalOptions = {} ): AnySignal => { const defaultPayload = (options as SignalOptionsWithPayload).payload; let lastContext: EffectContext | undefined; let cancelled = false; let payload = defaultPayload; let error: any; const onError = createCallbackGroup(); const onEmit = createCallbackGroup(); const onLoading = createCallbackGroup(); if (options?.onError) { onError.add(options.onError); } return Object.assign( (nextPayload?: Payload, noDispose: boolean = false): any => { if (typeof nextPayload !== "function") { const p = nextPayload; nextPayload = () => p; } cancelled = false; error = undefined; lastContext = undefined; const context = createSignalContext(); try { const result = (nextPayload as Function)(context); if (result === false) return; if (isPromiseLike(result)) { lastContext = context; const promise = Object.assign( new Promise((resolve, reject) => { result .then((value) => { if (lastContext !== context) return; payload = value; lastContext = undefined; context.dispose(); onEmit.call(); resolve(payload); }) .catch((reason) => { if (lastContext !== context) return; lastContext = undefined; error = reason; !noDispose && context.dispose(); onError.call(reason); reject(reason); }); }), { cancel() { (result as CancellablePromise)?.cancel?.(); }, } ); promise.catch(noop); context.promise = promise; onLoading.call(promise); return promise; } payload = result; !noDispose && context.dispose(); onEmit.call(); } catch (ex) { error = ex; !noDispose && context.dispose(); onError.call(ex); } }, { $$type: signalType, key: options?.key, onEmit: onEmit.add, onError: onError.add, onLoading: onLoading.add, payload: () => payload, error: () => error, async: () => lastContext?.promise, reset(removeAllListeners = false) { error = undefined; lastContext = undefined; cancelled = false; payload = defaultPayload; if (removeAllListeners) { onEmit.clear(); onError.clear(); onLoading.clear(); } onEmit.call(); }, cancel() { if (!lastContext) return; lastContext.cancel(); error = undefined; lastContext = undefined; cancelled = true; options?.onCancel?.(); }, cancelled: () => cancelled, } ) as unknown as AnySignal; }; export const useSignal = ( signals: SignalList, { suspense = true, errorBoundary = true }: UseSignalOptions = {} ) => { const rerender = useState()[1]; const optionsRef = useRef({}); optionsRef.current = { suspense, errorBoundary }; const ref = useState(() => { let rendering = false; const onDispose = createCallbackGroup(); const registered = new Set(); const handleChange = () => { if (rendering) return; rerender({}); }; const registerSignal = (signals: SignalList) => { forEachSignal(signals, (signal) => { if (registered.has(signal)) return; registered.add(signal); onDispose.add(signal.onLoading(handleChange)); onDispose.add(signal.onError(handleChange)); onDispose.add(signal.onEmit(handleChange)); }); }; return { registerSignal, setIsRendering(value: boolean) { rendering = value; }, dispose() { registered.clear(); onDispose.call(); }, }; })[0]; ref.setIsRendering(true); useEffect(() => { ref.setIsRendering(false); ref.registerSignal(signals); }); useEffect(() => ref.dispose, [ref]); if (suspense || errorBoundary) { forEachSignal(signals, (signal) => { if (errorBoundary && signal.error()) { throw signal.error(); } if (suspense) { const promise = signal.async(); if (promise) throw promise; } }); } };