import { Signal, batch, effect, signal, untracked, } from "@preact-signals/unified-signals"; import { FlatStore, createFlatStore } from "../flat-store"; import { FlatStoreSetter } from "../flat-store/setter"; import { Dispose } from "../hooks/utility"; import { Accessor, AnyReactive, GetTruthyValue, GetValue, Setter, isExplicitFalsy, } from "../utils"; const NO_INIT = Symbol("NO_INIT"); /*@__NO_SIDE_EFFECTS__*/ const isPromise = (value: unknown): value is Promise => !!value && typeof value === "object" && value instanceof Promise; /*@__NO_SIDE_EFFECTS__*/ const removeNoInit = (value: T | typeof NO_INIT) => value !== NO_INIT ? value : undefined; /** * A resource that waits for a source to be truthy before fetching. */ export interface Unresolved { state: "unresolved"; loading: false; error: undefined; latest: T | undefined; (): undefined; } export interface Pending { state: "pending"; loading: true; error: undefined; latest: T | undefined; (): undefined; } export interface Ready { state: "ready"; loading: false; error: undefined; latest: T; (): T; } export interface Refreshing { state: "refreshing"; loading: true; error: undefined; latest: T | undefined; (): undefined; } export interface Errored { state: "errored"; loading: false; error: unknown; latest: T | undefined; (): undefined; } /* Unresolved --> Pending : fetch Pending --> Ready : resolve Pending --> Errored : reject Ready --> Refreshing : refresh Refreshing --> Ready : resolve Refreshing --> Errored : reject Errored --> Refreshing : refresh */ export type ResourceState = | Unresolved | Pending | Ready | Refreshing | Errored; export type InitializedResource = Ready | Refreshing | Errored; export type ResourceActions = { mutate: Setter; refetch: (info?: TRefetch) => TResult | Promise | undefined | null; }; export type ResourceSource = () => S | false | null | undefined; export type ResourceFetcher = ( k: TSourceData, info: ResourceFetcherInfo ) => TResult | Promise; export type ResourceFetcherInfo = { value: TSourceData | undefined; refetching: TRefreshing | boolean; /** will be aborted if source is updated or resource disposed */ signal: AbortSignal; }; export type ResourceOptions< TResult, TSource extends AnyReactive = Accessor, TRefreshing = boolean, TSourceData extends GetTruthyValue = GetTruthyValue > = | { /** * Optional. An initial value for the resource. If is provided resource will be in ready state. */ initialValue?: TResult; /** * Optional. A function or signal that can be used as a source for fetching the resource. * This can be useful if you need to base your fetch operation on the value of another signal or even resource */ source?: TSource; /** * A function that is used to fetch or refresh the resource. */ fetcher: ResourceFetcher; } & ( | { /** * lazy: Optional. If true, the resource will not be fetched until access of ResourceState properties. */ lazy?: boolean; } | { /** * Optional. If true, the resource will not subscribe to the source signal, before be activated. */ manualActivation?: boolean; } ); type ResourceStore = { state: ResourceState["state"]; value: TResult | undefined | typeof NO_INIT; error: unknown; latest: TResult | undefined; readonly source: GetValue; readonly callResult: TResult | undefined; }; export type Resource< TResult, TSource extends AnyReactive = Accessor, TRefreshing = boolean, TSourceData extends GetTruthyValue = GetTruthyValue > = ResourceState & { /** * A function that should be used to activate the resource with manualActivation option enabled. */ activate(): Dispose; dispose(): void; mutate: ResourceActions["mutate"]; refetch: ResourceActions["refetch"]; /** @internal */ pr: Promise | null; /** @internal */ _state: FlatStore>; /** @internal */ setter: FlatStoreSetter>; /** @internal */ abortController: AbortController | null; /** @internal */ _onRead(): void; /** @internal */ manualActivation: boolean; /** @internal */ refreshDummy$: Signal; /** @internal */ refetchData: boolean | TRefreshing; /** @internal */ refetchDetector(): { source: GetValue; refetching: TRefreshing | boolean; }; /** @internal */ refetchEffect: null | (() => void); /** @internal */ fetcher: ResourceFetcher; /** @internal */ get initialized(): boolean; /** @internal */ isInitialValueProvided: boolean; /** @internal */ _read(): ReturnType>; /** @internal */ _init(): void; /** @internal */ _fetch(data: { source: GetTruthyValue; refetching: TRefreshing | boolean; }): void; /** @internal */ _latest(): TResult | undefined; /** @internal */ _refetch: ResourceActions["refetch"]; /** @internal */ _mutate: ResourceActions["mutate"]; }; function Resource< TResult, TSource extends AnyReactive = Accessor, TRefreshing = boolean, TSourceData extends GetTruthyValue = GetTruthyValue >(options: ResourceOptions) { const self = (() => { return self._read(); }) as Resource; Object.setPrototypeOf(self, Resource.prototype); const initialValueProvided = (options.initialValue ?? NO_INIT) !== NO_INIT; const [store, setter] = createFlatStore>({ get source() { const source = options.source; if (!source) { return true; } if (typeof source === "function") { return source(); } return source.value; }, latest: options.initialValue ?? undefined, value: options.initialValue ?? NO_INIT, error: undefined, state: initialValueProvided ? "ready" : "unresolved", get callResult() { if (this.state !== "ready") { return undefined; } return removeNoInit(this.value); }, }); self._state = store; self.setter = setter; self.pr = null; self.refetchData = initialValueProvided; self.fetcher = options.fetcher; self.refreshDummy$ = signal(false); self.refetchEffect = null; self.isInitialValueProvided = initialValueProvided; self.manualActivation = "manualActivation" in options ? !!options.manualActivation : false; // @ts-expect-error union stuff if (!options?.lazy && !self.manualActivation) { self._init(); } return self; } Resource.prototype = Object.create(Function.prototype); const refetchDetector: Resource["refetchDetector"] = function (this: Resource) { this.refreshDummy$.value; return { source: this._state.source, refetching: this.refetchData, }; }; const _init: Resource["_init"] = function ( this: Resource ) { let skipFetch = this.isInitialValueProvided; this.refetchEffect = effect(() => { const { source, refetching } = this.refetchDetector(); if (isExplicitFalsy(source)) { this.setter({ state: "unresolved", error: undefined, value: NO_INIT, }); return; } if (skipFetch) { skipFetch = false; return; } this.abortController?.abort(); this.abortController = null; untracked(() => this._fetch({ source, refetching })); }); }; const _fetch: Resource["_fetch"] = function ( this: Resource, data ) { const currentState = this._state.state; const fetcher = this.fetcher; batch(() => { if (currentState === "errored" || currentState === "ready") { this._state.state = "refreshing"; } if (currentState === "unresolved") { this._state.state = "pending"; } const value = removeNoInit(this._state.value); this._state.latest = value; this._state.value = undefined; let result: unknown | Promise; const abortController = new AbortController(); this.abortController = abortController; try { result = fetcher(data.source, { value: value, refetching: data.refetching, signal: this.abortController.signal, }); } catch (e) { this.setter({ state: "errored", error: e, }); this.abortController = null; return; } if (!isPromise(result)) { this.abortController = null; this.setter({ value: result, latest: result, state: "ready", }); this.refetchData = true; return; } this.refetchData = true; if (!result) { return; } this.pr = result.then( (value) => { if (abortController.signal.aborted) { return; } this.pr = null; this.setter({ value, latest: value, state: "ready", }); }, (error) => { if (abortController.signal.aborted) { return; } this.pr = null; this.setter({ error, state: "errored", }); } ); }); }; const _read: Resource["_read"] = function ( this: Resource ) { this._onRead(); return this._state.callResult; }; const _latest: Resource["_latest"] = function ( this: Resource ) { this._onRead(); return removeNoInit(this._state.latest); }; const _refetch: Resource["_refetch"] = function ( this: Resource, customRefetching: unknown ) { if (customRefetching !== undefined) { this.refetchData = customRefetching; } this.refreshDummy$.value = !this.refreshDummy$.peek(); }; const _mutate: Resource["_mutate"] = function ( this: Resource, updater: Setter ) { const updaterFn = typeof updater === "function" ? updater : () => updater; return untracked(() => { this.setter({ state: "ready", error: undefined, value: updaterFn(this._state.value), }); }); }; const dispose: Resource["dispose"] = function ( this: Resource ) { this.refetchEffect?.(); this.abortController?.abort(); this.setter({ state: "unresolved", error: undefined, value: NO_INIT, }); this.refetchEffect = null; }; const _onRead: Resource["_onRead"] = function ( this: Resource ) { if (!this.initialized && !this.manualActivation) { this._init(); } }; const activate: Resource["activate"] = function ( this: Resource ) { if (!this.initialized) { this._init(); } return this.dispose.bind(this); }; Resource.prototype.refetchDetector = refetchDetector; Resource.prototype._refetch = _refetch; Resource.prototype._read = _read; Resource.prototype._fetch = _fetch; Resource.prototype._init = _init; Resource.prototype._latest = _latest; Resource.prototype._mutate = _mutate; Resource.prototype._onRead = _onRead; Resource.prototype.activate = activate; Object.defineProperty(Resource.prototype, "initialized", { get(this: Resource) { return this.refetchEffect !== null; }, }); // public api Object.defineProperties(Resource.prototype, { latest: { get(this: Resource) { return this._latest(); }, }, state: { get(this: Resource) { this._onRead(); return this._state.state; }, }, error: { get(this: Resource) { this._onRead(); return this._state.error; }, }, loading: { get(this: Resource) { this._onRead(); return ( this._state.state === "pending" || this._state.state === "refreshing" ); }, }, // binding to not miss this dispose: { get(this: Resource) { return dispose.bind(this); }, }, refetch: { get(this: Resource) { return this._refetch.bind(this); }, }, mutate: { get(this: Resource) { return this._mutate.bind(this); }, }, }); /** * More preact signals like resource api */ export const resource = < TResult, TSource extends AnyReactive = Accessor, TRefreshing = boolean, TSourceData extends GetTruthyValue = GetTruthyValue >( options: ResourceOptions ) => Resource(options);