diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e7799..46c747a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog # +notes: + +- state store now always included +- all mapping load functions are rebounced by default +- if an async store loses all subscriptions and then gains one the mapping load function will always evaluate even if the inputs have not changed +- can't use stores to hold errors +- rebounce clear is now called abort + ## 1.0.17 (2023-6-20) - *BREAKING CHANGE* chore: rearrange dependencies to minimize installed package size @@ -8,7 +16,7 @@ ## 1.0.16 (2023-6-20) -- fix: moduleResoltion: NoodeNext support +- fix: moduleResoltion: NodeNext support - feat: allow for custom storage types for persisted stores ## 1.0.15 (2023-2-27) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index e1f124a..89574e5 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -1,19 +1,26 @@ -import { get, type Updater, type Readable, writable } from 'svelte/store'; +import { + get, + type Updater, + writable, + StartStopNotifier, + Writable, +} from 'svelte/store'; import type { AsyncStoreOptions, - Loadable, LoadState, State, Stores, StoresValues, WritableLoadable, VisitedMap, + AsyncLoadable, } from './types.js'; import { - anyReloadable, getStoresArray, reloadAll, loadAll, + rebounce, + getAll, } from '../utils/index.js'; import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; @@ -39,9 +46,9 @@ const getLoadState = (stateString: State): LoadState => { * and then execute provided asynchronous behavior to persist this change. * @param stores Any readable or array of Readables whose value is used to generate the asynchronous behavior of this store. * Any changes to the value of these stores post-load will restart the asynchronous behavior of the store using the new values. - * @param mappingLoadFunction A function that takes in the values of the stores and generates a Promise that resolves + * @param selfLoadFunction A function that takes in the loaded values of any parent stores and generates a Promise that resolves * to the final value of the store when the asynchronous behavior is complete. - * @param mappingWriteFunction A function that takes in the new value of the store and uses it to perform async behavior. + * @param writePersistFunction A function that takes in the new value of the store and uses it to perform async behavior. * Typically this would be to persist the change. If this value resolves to a value the store will be set to it. * @param options Modifiers for store behavior. * @returns A Loadable store whose value is set to the resolution of provided async behavior. @@ -49,124 +56,137 @@ const getLoadState = (stateString: State): LoadState => { */ export const asyncWritable = ( stores: S, - mappingLoadFunction: (values: StoresValues) => Promise | T, - mappingWriteFunction?: ( + selfLoadFunction: (values: StoresValues) => Promise | T, + writePersistFunction?: ( value: T, parentValues?: StoresValues, oldValue?: T ) => Promise, options: AsyncStoreOptions = {} ): WritableLoadable => { - flagStoreCreated(); - const { reloadable, trackState, initial } = options; + // eslint-disable-next-line prefer-const + let thisStore: Writable; - const loadState = trackState - ? writable(getLoadState('LOADING')) - : undefined; + flagStoreCreated(); + const { reloadable, initial, rebounceDelay } = options; - const setState = (state: State) => loadState?.set(getLoadState(state)); + const rebouncedSelfLoad = rebounce(selfLoadFunction, rebounceDelay); - // stringified representation of parents' loaded values - // used to track whether a change has occurred and the store reloaded - let loadedValuesString: string; + const loadState = writable(getLoadState('LOADING')); + const setState = (state: State) => loadState.set(getLoadState(state)); - let latestLoadAndSet: () => Promise; + // flag marking whether store is ready for updates from subscriptions + let ready = false; + let changeReceived = false; // most recent call of mappingLoadFunction, including resulting side effects // (updating store value, tracking state, etc) - let currentLoadPromise: Promise; + let currentLoadPromise: Promise; + let resolveCurrentLoad: (value: T | PromiseLike | Error) => void; - const tryLoad = async (values: StoresValues) => { - try { - return await mappingLoadFunction(values); - } catch (e) { - if (e.name !== 'AbortError') { - logError(e); - setState('ERROR'); - } - throw e; - } + const setCurrentLoadPromise = () => { + currentLoadPromise = new Promise((resolve) => { + resolveCurrentLoad = resolve; + }); }; - // eslint-disable-next-line prefer-const - let loadDependenciesThenSet: ( - parentLoadFunction: (stores: S) => Promise>, - forceReload?: boolean - ) => Promise; - - const thisStore = writable(initial, () => { - loadDependenciesThenSet(loadAll).catch(() => Promise.resolve()); - - const parentUnsubscribers = getStoresArray(stores).map((store) => - store.subscribe(() => { - loadDependenciesThenSet(loadAll).catch(() => Promise.resolve()); - }) - ); - - return () => { - parentUnsubscribers.map((unsubscriber) => unsubscriber()); - }; - }); + const getLoadedValueOrThrow = async (callback?: () => void) => { + const result = await currentLoadPromise; + callback?.(); + if (result instanceof Error) { + throw result; + } + return currentLoadPromise as T; + }; - loadDependenciesThenSet = async ( - parentLoadFunction: (stores: S) => Promise>, - forceReload = false - ) => { - const loadParentStores = parentLoadFunction(stores); + let mostRecentLoadTracker: Record; + const selfLoadThenSet = async (parentValues: StoresValues) => { + const thisLoadTracker = {}; + mostRecentLoadTracker = thisLoadTracker; try { - await loadParentStores; - } catch { - currentLoadPromise = loadParentStores as Promise; - setState('ERROR'); - return currentLoadPromise; - } + // parentValues + const finalValue = (await rebouncedSelfLoad(parentValues)) as T; + thisStore.set(finalValue); - const storeValues = getStoresArray(stores).map((store) => - get(store) - ) as StoresValues; + if (!get(loadState).isWriting) { + setState('LOADED'); + } + resolveCurrentLoad(finalValue); + } catch (error) { + if (error.name === 'AbortError') { + if (thisLoadTracker === mostRecentLoadTracker) { + // Normally when a load is aborted we want to leave the state as is. + // However if the latest load is aborted we change back to LOADED + // so that it does not get stuck LOADING/RELOADING. + setState('LOADED'); + resolveCurrentLoad(get(thisStore)); + } + } else { + logError(error); + setState('ERROR'); - if (!forceReload) { - const newValuesString = JSON.stringify(storeValues); - if (newValuesString === loadedValuesString) { - // no change, don't generate new promise - return currentLoadPromise; + // Resolve with an Error rather than rejecting so that unhandled rejections + // are not created by the store's internal processes. These errors are + // converted back to promise rejections via the load or reload functions, + // allowing for proper handling after that point. + // If your stack trace takes you here, make sure your store's + // selfLoadFunction rejects with an Error to preserve the full trace. + resolveCurrentLoad(error instanceof Error ? error : new Error(error)); } - loadedValuesString = newValuesString; } + }; - // convert storeValues to single store value if expected by mapping function - const loadInput = Array.isArray(stores) ? storeValues : storeValues[0]; + let cleanupSubscriptions: () => void; - const loadAndSet = async () => { - latestLoadAndSet = loadAndSet; - if (get(loadState)?.isSettled) { - setState('RELOADING'); - } + // called when store receives its first subscriber + const onFirstSubscription: StartStopNotifier = () => { + setCurrentLoadPromise(); + setState('LOADING'); + + const initialLoad = async () => { try { - const finalValue = await tryLoad(loadInput); - thisStore.set(finalValue); - setState('LOADED'); - return finalValue; - } catch (e) { - // if a load is aborted, resolve to the current value of the store - if (e.name === 'AbortError') { - // Normally when a load is aborted we want to leave the state as is. - // However if the latest load is aborted we change back to LOADED - // so that it does not get stuck LOADING/RELOADIN'. - if (loadAndSet === latestLoadAndSet) { - setState('LOADED'); - } - return get(thisStore); + const parentValues = await loadAll(stores); + ready = true; + changeReceived = false; + selfLoadThenSet(parentValues); + } catch (error) { + ready = true; + changeReceived = false; + resolveCurrentLoad(error); + } + }; + initialLoad(); + + const onSubscriptionUpdate = async () => { + changeReceived = true; + if (ready) { + if (get(loadState).isSettled) { + setCurrentLoadPromise(); + setState('RELOADING'); } - throw e; + ready = false; + const parentValues = await loadAll(stores); + // eslint-disable-next-line require-atomic-updates + ready = true; + selfLoadThenSet(parentValues); } }; - currentLoadPromise = loadAndSet(); - return currentLoadPromise; + const parentUnsubscribers = getStoresArray(stores).map((store) => + store.subscribe(onSubscriptionUpdate) + ); + + // called on losing last subscriber + return () => { + parentUnsubscribers.map((unsubscriber) => unsubscriber()); + ready = false; + changeReceived = false; + }; }; + thisStore = writable(initial, onFirstSubscription); + const setStoreValueThenWrite = async ( updater: Updater, persist?: boolean @@ -174,72 +194,94 @@ export const asyncWritable = ( setState('WRITING'); let oldValue: T; try { - oldValue = await loadDependenciesThenSet(loadAll); + oldValue = await getLoadedValueOrThrow(); } catch { oldValue = get(thisStore); } - const newValue = updater(oldValue); - currentLoadPromise = currentLoadPromise - .then(() => newValue) - .catch(() => newValue); + + setCurrentLoadPromise(); + let newValue = updater(oldValue); thisStore.set(newValue); - if (mappingWriteFunction && persist) { + if (writePersistFunction && persist) { try { - const parentValues = await loadAll(stores); - - const writeResponse = (await mappingWriteFunction( + const writeResponse = (await writePersistFunction( newValue, - parentValues, + getAll(stores), oldValue )) as T; if (writeResponse !== undefined) { thisStore.set(writeResponse); - currentLoadPromise = currentLoadPromise.then(() => writeResponse); + newValue = writeResponse; } - } catch (e) { - logError(e); + } catch (error) { + logError(error); setState('ERROR'); - throw e; + resolveCurrentLoad(newValue); + throw error; } } + setState('LOADED'); + resolveCurrentLoad(newValue); }; // required properties const subscribe = thisStore.subscribe; + + const load = async () => { + const dummyUnsubscribe = thisStore.subscribe(() => { + /* no-op */ + }); + await loadAll(stores); + return getLoadedValueOrThrow(dummyUnsubscribe); + }; + + const reload = async (visitedMap?: VisitedMap) => { + const dummyUnsubscribe = thisStore.subscribe(() => { + /* no-op */ + }); + ready = false; + changeReceived = false; + if (get(loadState).isSettled) { + setCurrentLoadPromise(); + } + const wasErrored = get(loadState).isError; + setState('RELOADING'); + + const visitMap = visitedMap ?? new WeakMap(); + try { + const parentValues = await reloadAll(stores, visitMap); + ready = true; + if (changeReceived || reloadable || wasErrored) { + selfLoadThenSet(parentValues); + } else { + resolveCurrentLoad(get(thisStore)); + setState('LOADED'); + } + } catch (error) { + setState('ERROR'); + resolveCurrentLoad(error); + } + return getLoadedValueOrThrow(dummyUnsubscribe); + }; + const set = (newValue: T, persist = true) => setStoreValueThenWrite(() => newValue, persist); const update = (updater: Updater, persist = true) => setStoreValueThenWrite(updater, persist); - const load = () => loadDependenciesThenSet(loadAll); - - // // optional properties - const hasReloadFunction = Boolean(reloadable || anyReloadable(stores)); - const reload = hasReloadFunction - ? async (visitedMap?: VisitedMap) => { - const visitMap = visitedMap ?? new WeakMap(); - const reloadAndTrackVisits = (stores: S) => reloadAll(stores, visitMap); - setState('RELOADING'); - const result = await loadDependenciesThenSet( - reloadAndTrackVisits, - reloadable - ); - setState('LOADED'); - return result; - } - : undefined; - const state: Readable = loadState - ? { subscribe: loadState.subscribe } - : undefined; + const abort = () => { + rebouncedSelfLoad.abort(); + }; + const reset = getStoreTestingMode() ? () => { thisStore.set(initial); setState('LOADING'); - loadedValuesString = undefined; - currentLoadPromise = undefined; + ready = false; + changeReceived = false; } : undefined; @@ -248,11 +290,12 @@ export const asyncWritable = ( return this; }, subscribe, + load, + reload, set, update, - load, - ...(reload && { reload }), - ...(state && { state }), + abort, + state: { subscribe: loadState.subscribe }, ...(reset && { reset }), }; }; @@ -263,7 +306,7 @@ export const asyncWritable = ( * If so, this store will begin loading only after the parents have loaded. * @param stores Any readable or array of Readables whose value is used to generate the asynchronous behavior of this store. * Any changes to the value of these stores post-load will restart the asynchronous behavior of the store using the new values. - * @param mappingLoadFunction A function that takes in the values of the stores and generates a Promise that resolves + * @param selfLoadFunction A function that takes in the values of the stores and generates a Promise that resolves * to the final value of the store when the asynchronous behavior is complete. * @param options Modifiers for store behavior. * @returns A Loadable store whose value is set to the resolution of provided async behavior. @@ -271,12 +314,12 @@ export const asyncWritable = ( */ export const asyncDerived = ( stores: S, - mappingLoadFunction: (values: StoresValues) => Promise, + selfLoadFunction: (values: StoresValues) => Promise, options?: AsyncStoreOptions -): Loadable => { - const { store, subscribe, load, reload, state, reset } = asyncWritable( +): AsyncLoadable => { + const { store, subscribe, load, reload, state, abort, reset } = asyncWritable( stores, - mappingLoadFunction, + selfLoadFunction, undefined, options ); @@ -285,8 +328,9 @@ export const asyncDerived = ( store, subscribe, load, - ...(reload && { reload }), - ...(state && { state }), + reload, + state, + abort, ...(reset && { reset }), }; }; @@ -295,7 +339,7 @@ export const asyncDerived = ( * Generates a Loadable store that will start asynchronous behavior when subscribed to, * and whose value will be equal to the resolution of that behavior when completed. * @param initial The initial value of the store before it has loaded or upon load failure. - * @param loadFunction A function that generates a Promise that resolves to the final value + * @param selfLoadFunction A function that generates a Promise that resolves to the final value * of the store when the asynchronous behavior is complete. * @param options Modifiers for store behavior. * @returns A Loadable store whose value is set to the resolution of provided async behavior. @@ -303,8 +347,8 @@ export const asyncDerived = ( */ export const asyncReadable = ( initial: T, - loadFunction: () => Promise, + selfLoadFunction: () => Promise, options?: Omit, 'initial'> -): Loadable => { - return asyncDerived([], loadFunction, { ...options, initial }); +): AsyncLoadable => { + return asyncDerived([], selfLoadFunction, { ...options, initial }); }; diff --git a/src/async-stores/types.ts b/src/async-stores/types.ts index dd60eff..53a86ee 100644 --- a/src/async-stores/types.ts +++ b/src/async-stores/types.ts @@ -16,28 +16,34 @@ export type VisitedMap = WeakMap, Promise>; export interface Loadable extends Readable { load(): Promise; - reload?(visitedMap?: VisitedMap): Promise; - state?: Readable; reset?(): void; store: Loadable; } -export interface Reloadable extends Loadable { +export interface Reloadable { reload(visitedMap?: VisitedMap): Promise; } +export interface AsyncLoadable extends Loadable { + reload(visitedMap?: VisitedMap): Promise; + abort(): void; + state: Readable; + store: AsyncLoadable; +} + export interface AsyncWritable extends Writable { set(value: T, persist?: boolean): Promise; update(updater: Updater): Promise; store: AsyncWritable; } -export type WritableLoadable = Loadable & AsyncWritable; +export type WritableLoadable = AsyncLoadable & AsyncWritable; export interface AsyncStoreOptions { reloadable?: true; trackState?: true; initial?: T; + rebounceDelay?: number; } export declare type StoresArray = | [Readable, ...Array>] diff --git a/src/persisted/types.ts b/src/persisted/types.ts index 1d4de7f..106e6ec 100644 --- a/src/persisted/types.ts +++ b/src/persisted/types.ts @@ -1,4 +1,4 @@ -import { WritableLoadable } from '../async-stores/types.js'; +import { AsyncWritable, Loadable, Reloadable } from '../async-stores/types.js'; export type StorageType = 'LOCAL_STORAGE' | 'SESSION_STORAGE' | 'COOKIE'; @@ -14,4 +14,7 @@ interface Syncable { store: Syncable; } -export type Persisted = Syncable & WritableLoadable; +export type Persisted = Syncable & + Loadable & + Reloadable & + AsyncWritable; diff --git a/src/standard-stores/index.ts b/src/standard-stores/index.ts index dd9d801..4248b99 100644 --- a/src/standard-stores/index.ts +++ b/src/standard-stores/index.ts @@ -9,9 +9,10 @@ import { type Writable, writable as vanillaWritable, } from 'svelte/store'; -import { anyReloadable, loadAll, reloadAll } from '../utils/index.js'; +import { loadAll, reloadAll } from '../utils/index.js'; import type { Loadable, + Reloadable, Stores, StoresValues, VisitedMap, @@ -58,7 +59,7 @@ export function derived( stores: S, fn: SubscribeMapper, initialValue?: T -): Loadable; +): Loadable & Reloadable; /** * A Derived store that is considered 'loaded' when all of its parents have loaded (and so on). @@ -73,25 +74,23 @@ export function derived( stores: S, mappingFunction: DerivedMapper, initialValue?: T -): Loadable; +): Loadable & Reloadable; // eslint-disable-next-line func-style export function derived( stores: S, fn: DerivedMapper | SubscribeMapper, initialValue?: T -): Loadable { +): Loadable & Reloadable { flagStoreCreated(); const thisStore = vanillaDerived(stores, fn as any, initialValue); const load = () => loadDependencies(thisStore, loadAll, stores); - const reload = anyReloadable(stores) - ? (visitedMap?: VisitedMap) => { - const visitMap = visitedMap ?? new WeakMap(); - const reloadAndTrackVisits = (stores: S) => reloadAll(stores, visitMap); - return loadDependencies(thisStore, reloadAndTrackVisits, stores); - } - : undefined; + const reload = (visitedMap?: VisitedMap) => { + const visitMap = visitedMap ?? new WeakMap(); + const reloadAndTrackVisits = (stores: S) => reloadAll(stores, visitMap); + return loadDependencies(thisStore, reloadAndTrackVisits, stores); + }; return { get store() { @@ -99,7 +98,7 @@ export function derived( }, ...thisStore, load, - ...(reload && { reload }), + reload, }; } @@ -133,14 +132,28 @@ export const writable = ( loadPromise = Promise.resolve(value); }; - const startFunction: StartStopNotifier = (set: Subscriber) => { + const startFunction: StartStopNotifier = ( + set: Subscriber, + update: (fn: Updater) => void + ) => { const customSet = (value: T) => { set(value); updateLoadPromise(value); }; + + const customUpdate = (evaluate: Updater) => { + let newValue: T; + const customEvaluate: Updater = (value: T) => { + newValue = evaluate(value); + return newValue; + }; + update(customEvaluate); + updateLoadPromise(newValue); + }; + // intercept the `set` function being passed to the provided start function // instead provide our own `set` which also updates the load promise. - return start(customSet); + return start(customSet, customUpdate); }; const thisStore = vanillaWritable(value, start && startFunction); diff --git a/src/utils/index.ts b/src/utils/index.ts index d309ed6..fc4782e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -71,7 +71,10 @@ export const reloadAll = async ( if (Object.prototype.hasOwnProperty.call(store, 'reload')) { // only reload if store has not already been visited if (!visitMap.has(store)) { - visitMap.set(store, (store as Loadable).reload(visitMap)); + visitMap.set( + store, + (store as unknown as Reloadable).reload(visitMap) + ); } return visitMap.get(store); } else if (Object.prototype.hasOwnProperty.call(store, 'load')) { @@ -117,7 +120,7 @@ export const rebounce = ( callback: (...args: T[]) => U, delay = 0 ): ((...args: T[]) => FlatPromise) & { - clear: () => void; + abort: () => void; } => { let previousReject: (reason: Error) => void; let existingTimer: ReturnType; @@ -151,7 +154,7 @@ export const rebounce = ( return currentPromise; }; - const clear = () => { + const abort = () => { clearTimeout(existingTimer); previousReject?.( new DOMException('The function was rebounced.', 'AbortError') @@ -160,7 +163,7 @@ export const rebounce = ( previousReject = undefined; }; - rebounced.clear = clear; + rebounced.abort = abort; return rebounced; }; diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index e9c7666..fe775dc 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -9,10 +9,9 @@ import { derived, readable, writable, - isReloadable, - rebounce, safeLoad, } from '../../src'; +import { delayValue, delayFunction } from '../helpers'; describe('asyncWritable', () => { const writableParent = writable('writable'); @@ -60,12 +59,14 @@ describe('asyncWritable', () => { expect(get(isInitial)).toBe(true); }); - it('does not reload if not reloadable', () => { + it('does not reload if not reloadable', async () => { const myAsyncDerived = asyncReadable(undefined, mockReload); myAsyncDerived.subscribe(vi.fn()); - expect(myAsyncDerived.load()).resolves.toBe('first value'); - expect(isReloadable(myAsyncDerived)).toBeFalsy(); + let result = await myAsyncDerived.load(); + expect(result).toBe('first value'); + result = await myAsyncDerived.reload(); + expect(result).toBe('first value'); }); it('does reload if reloadable', async () => { @@ -100,6 +101,7 @@ describe('asyncWritable', () => { () => Promise.reject(new Error('error')), { initial: 'initial' } ); + myAsyncDerived.subscribe(vi.fn()); expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); @@ -107,12 +109,14 @@ describe('asyncWritable', () => { expect(get(myAsyncDerived)).toBe('initial'); }); - it('does not reload if not reloadable', () => { + it('does not reload if not reloadable', async () => { const myAsyncDerived = asyncDerived(writableParent, mockReload); myAsyncDerived.subscribe(vi.fn()); - expect(myAsyncDerived.load()).resolves.toBe('first value'); - expect(isReloadable(myAsyncDerived)).toBeFalsy(); + let result = await myAsyncDerived.load(); + expect(result).toBe('first value'); + result = await myAsyncDerived.reload(); + expect(result).toBe('first value'); }); it('does reload if reloadable', async () => { @@ -139,15 +143,17 @@ describe('asyncWritable', () => { expect(get(myAsyncDerived)).toBe('second value'); }); - it('loads asyncReadable parent', () => { + it('loads asyncReadable parent', async () => { const asyncReadableParent = asyncReadable(undefined, mockReload); const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); myAsyncDerived.subscribe(vi.fn()); - expect(myAsyncDerived.load()).resolves.toBe('derived from first value'); - expect(isReloadable(myAsyncDerived)).toBeFalsy(); + let result = await myAsyncDerived.load(); + expect(result).toBe('derived from first value'); + result = await myAsyncDerived.reload(); + expect(result).toBe('derived from first value'); }); it('reloads reloadable parent', async () => { @@ -184,19 +190,25 @@ describe('asyncWritable', () => { expect(mockReload).toHaveBeenCalledTimes(2); }); - it('rejects load when parent load fails', () => { - const asyncReadableParent = asyncReadable(undefined, () => - Promise.reject(new Error('error')) + it('rejects load and reload when parent load fails', () => { + const asyncReadableParent = asyncReadable( + undefined, + () => Promise.reject(new Error('error')), + { reloadable: true } ); + expect(asyncReadableParent.load()).rejects.toStrictEqual( + new Error('error') + ); + const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); - myAsyncDerived.subscribe(vi.fn()); expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); + expect(myAsyncDerived.reload()).rejects.toStrictEqual(new Error('error')); }); - it('correcly unsubscribes from parents', async () => { + it('correctly unsubscribes from parents', async () => { const writableParent = writable('initial'); const firstDerivedLoad = vi.fn(($parent) => Promise.resolve(`${$parent} first`) @@ -216,7 +228,7 @@ describe('asyncWritable', () => { // this sucks but I can't figure out a better way to wait for the // subscribe callbacks to get called without generating a new subscription - await new Promise((resolve) => setTimeout(resolve)); + await delayValue(null, 100); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('initial second'); @@ -226,7 +238,7 @@ describe('asyncWritable', () => { firstUnsubscribe(); writableParent.set('updated'); - await new Promise((resolve) => setTimeout(resolve)); + await delayValue(null, 50); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('updated second'); @@ -236,19 +248,57 @@ describe('asyncWritable', () => { describe('abort/rebounce integration', () => { it('loads to rebounced value only', async () => { + const load = (value: string) => delayValue(value, 100); + + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived(myParent, load); + + let setIncorrectly = false; + myStore.subscribe((value) => { + if (['a', 'b'].includes(value)) { + setIncorrectly = true; + } + }); + + let everErrored = false; + myState.subscribe((state) => { + if (state.isError) { + everErrored = true; + } + }); + + myParent.set('a'); + await delayValue(null, 50); + expect(get(myState).isLoading).toBe(true); + + myParent.set('b'); + await delayValue(null, 50); + expect(get(myState).isLoading).toBe(true); + + myParent.set('c'); + await delayValue(null, 50); + expect(get(myState).isLoading).toBe(true); + + const finalValue = await myStore.load(); + expect(everErrored).toBe(false); + expect(setIncorrectly).toBe(false); + expect(finalValue).toBe('c'); + expect(get(myStore)).toBe('c'); + expect(get(myState).isLoaded).toBe(true); + }); + + it('uses rebounce delay', async () => { const load = (value: string) => { - return new Promise((resolve) => - setTimeout(() => resolve(value), 100) - ); + return Promise.resolve(value); }; - const rebouncedLoad = rebounce(load); const myParent = writable(); const { store: myStore, state: myState } = asyncDerived( myParent, - rebouncedLoad, + load, { trackState: true, + rebounceDelay: 100, } ); @@ -267,17 +317,18 @@ describe('asyncWritable', () => { }); myParent.set('a'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); expect(get(myState).isLoading).toBe(true); + myParent.set('b'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); expect(get(myState).isLoading).toBe(true); + myParent.set('c'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); expect(get(myState).isLoading).toBe(true); const finalValue = await myStore.load(); - expect(everErrored).toBe(false); expect(setIncorrectly).toBe(false); expect(finalValue).toBe('c'); @@ -285,52 +336,82 @@ describe('asyncWritable', () => { expect(get(myState).isLoaded).toBe(true); }); - it('can be cleared correctly', async () => { + it('loads last called value instead of last resolved', async () => { + let timesCalled = 0; const load = (value: string) => { - return new Promise((resolve) => - setTimeout(() => resolve(value), 100) - ); + timesCalled += 1; + return delayValue(value, 200 - timesCalled * 100); }; - const rebouncedLoad = rebounce(load); const myParent = writable(); const { store: myStore, state: myState } = asyncDerived( myParent, - rebouncedLoad, + load, { trackState: true, } ); - myStore.subscribe(vi.fn()); + let setIncorrectly = false; + myStore.subscribe((value) => { + if (['a'].includes(value)) { + setIncorrectly = true; + } + }); + + myParent.set('a'); + myParent.set('b'); + + const result = await myStore.load(); + expect(result).toBe('b'); + + await delayValue(null, 200); + expect(get(myStore)).toBe('b'); + expect(setIncorrectly).toBe(false); + expect(get(myState).isLoaded).toBe(true); + }); + + it('resolves initial load if reloaded during', async () => { + const getFinalValue = vi + .fn() + .mockReturnValueOnce('first') + .mockReturnValueOnce('second'); + + const myLoadable = asyncReadable( + 'initial', + delayFunction(getFinalValue, 100), + { + reloadable: true, + } + ); + + expect(myLoadable.load()).resolves.toBe('second'); + await delayValue(null, 50); + const finalValue = await myLoadable.reload(); + expect(finalValue).toBe('second'); + }); + + it('can be aborted correctly', async () => { + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived( + myParent, + delayFunction((value) => value, 100) + ); + myStore.subscribe(vi.fn()); myParent.set('one'); let loadValue = await myStore.load(); expect(loadValue).toBe('one'); myParent.set('two'); - await new Promise((resolve) => setTimeout(resolve, 50)); - rebouncedLoad.clear(); - loadValue = await myStore.load(); + await delayValue(null, 50); + myStore.abort(); + loadValue = await myStore.load(); expect(loadValue).toBe('one'); expect(get(myStore)).toBe('one'); expect(get(myState).isLoaded).toBe(true); }); - - it('rejects load when rebounce reject', () => { - const rebouncedReject = rebounce( - () => Promise.reject(new Error('error')), - 100 - ); - const parent = writable(); - const rejectStore = asyncDerived(parent, () => rebouncedReject()); - - parent.set('value'); - expect(() => rejectStore.load()).rejects.toStrictEqual( - new Error('error') - ); - }); }); }); @@ -355,6 +436,7 @@ describe('asyncWritable', () => { expect(get(myAsyncDerived)).toBe( 'derived from writable, loadable, first value' ); + writableParent.set('new value'); await myAsyncDerived.load(); expect(get(myAsyncDerived)).toBe( @@ -369,12 +451,20 @@ describe('asyncWritable', () => { it('deterministically sets final value when receiving updates while loading', async () => { const delayedParent = asyncReadable( undefined, - () => new Promise((resolve) => setTimeout(resolve, 1000)) + delayFunction(() => null, 100) ); + const mockLoad = vi + .fn() + .mockReturnValue('default') + .mockResolvedValueOnce('first value') + .mockResolvedValueOnce('second value'); + const myDerived = asyncDerived( [writableParent, delayedParent], - ([$writableParent, $delayedParent]) => - mockReload().then((response) => `${$writableParent}: ${response}`) + async ([$writableParent, $delayedParent]) => { + const reloadValue = await mockLoad(); + return `${$writableParent}: ${reloadValue}`; + } ); myDerived.subscribe(vi.fn()); writableParent.set('A'); @@ -392,6 +482,46 @@ describe('asyncWritable', () => { await myDerived.load(); expect(get(myDerived)).toBe('L: first value'); }); + + it('updates once during concurrent changes', async () => { + const grandParentLoad = vi + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second') + .mockResolvedValueOnce('third'); + + const grandParent = asyncReadable(undefined, grandParentLoad, { + reloadable: true, + }); + const parentA = derived(grandParent, (value) => value); + const parentB = asyncDerived( + grandParent, + delayFunction((value) => value.toUpperCase(), 100) + ); + const parentC = asyncDerived( + parentB, + delayFunction((value) => value, 100) + ); + + const load = vi.fn( + delayFunction(([valueA, valueB]) => valueA + valueB, 100) + ); + const myLoadable = asyncDerived([parentA, parentC], load); + myLoadable.subscribe(vi.fn()); + + let result = await myLoadable.load(); + expect(result).toBe('firstFIRST'); + expect(load).toHaveBeenCalledOnce(); + + result = await myLoadable.reload(); + expect(result).toBe('secondSECOND'); + expect(load).toHaveBeenCalledTimes(2); + + await grandParent.reload(); + result = await myLoadable.load(); + expect(result).toBe('thirdTHIRD'); + expect(load).toHaveBeenCalledTimes(3); + }); }); describe('no parents asyncWritable', () => { @@ -595,14 +725,14 @@ describe('asyncWritable', () => { expect(mappingWriteFunction).toHaveBeenCalledTimes(1); }); - it('does not reload if not reloadable', () => { + it('does not reload if not reloadable', async () => { const myAsyncWritable = asyncWritable(writableParent, mockReload, () => Promise.resolve() ); myAsyncWritable.subscribe(vi.fn()); - expect(myAsyncWritable.load()).resolves.toBe('first value'); - expect(isReloadable(myAsyncWritable)).toBeFalsy(); + expect(await myAsyncWritable.load()).toBe('first value'); + expect(await myAsyncWritable.reload()).toBe('first value'); }); it('does reload if reloadable', async () => { @@ -634,7 +764,7 @@ describe('asyncWritable', () => { expect(get(myAsyncWritable)).toBe('second value'); }); - it('loads asyncReadable parent', () => { + it('loads asyncReadable parent', async () => { const asyncReadableParent = asyncReadable(undefined, mockReload); const myAsyncWritable = asyncWritable( asyncReadableParent, @@ -643,8 +773,8 @@ describe('asyncWritable', () => { ); myAsyncWritable.subscribe(vi.fn()); - expect(myAsyncWritable.load()).resolves.toBe('derived from first value'); - expect(isReloadable(myAsyncWritable)).toBeFalsy(); + expect(await myAsyncWritable.load()).toBe('derived from first value'); + expect(await myAsyncWritable.reload()).toBe('derived from first value'); }); it('can access asyncReadable parent loaded value while writing', async () => { @@ -709,13 +839,13 @@ describe('asyncWritable', () => { expect(get(myAsyncWritable)).toBe('derived from first value'); await myAsyncWritable.reload(); expect(get(myAsyncWritable)).toBe('derived from second value'); - expect(myAsyncWritable.load()).resolves.toBe('derived from second value'); + expect(await myAsyncWritable.load()).toBe('derived from second value'); await myAsyncWritable.set('set value'); expect(get(myAsyncWritable)).toBe('set value'); }); - it('rejects load when parent load fails', () => { + it('rejects load when parent load fails', async () => { const asyncReadableParent = asyncReadable(undefined, () => Promise.reject(new Error('error')) ); @@ -727,6 +857,7 @@ describe('asyncWritable', () => { myAsyncWritable.subscribe(vi.fn()); expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); + await safeLoad(myAsyncWritable); }); }); @@ -755,6 +886,8 @@ describe('asyncWritable', () => { Promise.reject(new Error('error')) ); + myReadable.subscribe(vi.fn()); + // perform multiple loads and make sure logger only called once await safeLoad(myReadable); await safeLoad(myReadable); @@ -859,7 +992,7 @@ describe('trackState', () => { }); }); - describe('adds state store when trackState enabled', () => { + describe('adds state store', () => { it('works with asyncWritable', async () => { const { store: myStore, state: myState } = asyncWritable( [], @@ -871,9 +1004,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); - - expect(get(myStore)).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -887,9 +1018,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); - - expect(get(myStore)).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -903,9 +1032,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); - - expect(get(myStore)).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); }); @@ -947,6 +1074,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(vi.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -976,6 +1105,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(vi.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -998,10 +1129,7 @@ describe('trackState', () => { const myParent = writable('initial'); const { store: myStore, state: myState } = asyncDerived( myParent, - ($myParent) => - new Promise((resolve) => - setTimeout(() => resolve(`derived from ${$myParent}`), 50) - ), + delayFunction(($myParent) => `derived from ${$myParent}`, 50), { trackState: true } ); @@ -1015,7 +1143,6 @@ describe('trackState', () => { expect(get(myState).isLoaded).toBe(true); myParent.set('updated'); - await new Promise((resolve) => setTimeout(resolve)); expect(get(myStore)).toBe('derived from initial'); expect(get(myState).isReloading).toBe(true); @@ -1038,16 +1165,12 @@ describe('trackState', () => { ); const { store: myStore, state: myState } = asyncDerived( [parentA, parentB], - ([$parentA, $parentB]) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(`${$parentA} ${$parentB}`); - }, 100); - }); - }, + delayFunction(([$parentA, $parentB]) => `${$parentA} ${$parentB}`, 100), { trackState: true } ); + myStore.subscribe(vi.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -1056,7 +1179,6 @@ describe('trackState', () => { expect(get(myState).isLoaded).toBe(true); grandParent.set('updated'); - await new Promise((resolve) => setTimeout(resolve)); expect(get(myStore)).toBe('initialA initialB'); expect(get(myState).isReloading).toBe(true); @@ -1092,6 +1214,9 @@ describe('trackState', () => { load, { trackState: true, reloadable: true } ); + + myStore.subscribe(vitest.fn()); + expect(get(myState).isLoading).toBe(true); await safeLoad(myStore); @@ -1117,6 +1242,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(vi.fn()); + expect(get(myState).isLoading).toBe(true); await safeLoad(myStore); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..6e8d516 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,17 @@ +type FlatPromise = T extends Promise ? T : Promise; + +export const delayValue = (value: T, delay = 0): FlatPromise => { + return new Promise((resolve) => + setTimeout(() => resolve(value), delay) + ) as FlatPromise; +}; + +export const delayFunction = ( + callback: (...args: T[]) => U, + delay = 0 +): ((...args: T[]) => FlatPromise) => { + return (...args: T[]) => { + const result = callback(...args); + return delayValue(result, delay); + }; +}; diff --git a/test/standard-stores/index.test.ts b/test/standard-stores/index.test.ts index 92c86a8..c141ba0 100644 --- a/test/standard-stores/index.test.ts +++ b/test/standard-stores/index.test.ts @@ -1,4 +1,4 @@ -import { get } from 'svelte/store'; +import { get, readable as vanillaReadable } from 'svelte/store'; import { asyncReadable, Loadable, @@ -6,6 +6,7 @@ import { readable, writable, } from '../../src'; +import { delayValue } from '../helpers'; describe('synchronous derived', () => { const nonAsyncParent = writable('writable'); @@ -206,8 +207,23 @@ describe('readable/writable stores', () => { }, 50); }); - const $myReadable = await myReadable.load(); - expect($myReadable).toBe('value'); + expect(await myReadable.load()).toBe('value'); + }); + + it('properly updates from start function', async () => { + const myReadable = readable(0, (_, update) => { + setTimeout(() => { + update((value) => value + 1); + }, 50); + setTimeout(() => { + update((value) => value + 1); + }, 100); + }); + + myReadable.subscribe(vi.fn()); + await delayValue(null, 200); + expect(get(myReadable)).toBe(2); + expect(await myReadable.load()).toBe(2); }); it('runs stop callback after loading with no subscriptions', async () => { @@ -227,7 +243,6 @@ describe('readable/writable stores', () => { expect(stop).toHaveBeenCalledTimes(1); await myReadable.load(); - await new Promise((resolve) => setTimeout(resolve)); expect(stop).toHaveBeenCalledTimes(2); }); }); diff --git a/test/testing.test.ts b/test/testing.test.ts index ef7de6c..75cbc9d 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -23,6 +23,7 @@ beforeEach(() => { describe('can be reset for different tests', () => { it('loads resolution', async () => { + const unsubscribe = myReadable.subscribe(vi.fn()); mockedFetch.mockResolvedValueOnce('loaded'); await myReadable.load(); @@ -32,9 +33,11 @@ describe('can be reset for different tests', () => { await myReadable.load(); expect(get(myReadable)).toBe('loaded'); + unsubscribe(); }); it('loads rejection', async () => { + const unsubscribe = myReadable.subscribe(vi.fn()); mockedFetch.mockRejectedValueOnce('rejected'); await myReadable.load().catch(() => Promise.resolve()); @@ -44,6 +47,7 @@ describe('can be reset for different tests', () => { await myReadable.load().catch(() => Promise.resolve()); expect(get(myReadable)).toBe('initial'); + unsubscribe(); }); }); diff --git a/test/utils/index.test.ts b/test/utils/index.test.ts index 7594537..b5b8e89 100644 --- a/test/utils/index.test.ts +++ b/test/utils/index.test.ts @@ -1,13 +1,13 @@ import { asyncReadable, Loadable, - readable, loadAll, rebounce, reloadAll, safeLoad, enableStoreTestingMode, } from '../../src'; +import { delayValue } from '../helpers'; enableStoreTestingMode(); @@ -20,7 +20,7 @@ describe('loadAll / reloadAll utils', () => { const badLoadable = { load: () => Promise.reject(new Error('E')), reload: () => Promise.reject(new Error('F')), - } as Loadable; + } as unknown as Loadable; beforeEach(() => { mockReload @@ -112,13 +112,8 @@ describe('rebounce', () => { const toUpperCase = (input: string) => input.toUpperCase(); - const asyncToUpperCase = (input: string) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(input.toUpperCase()); - }, interval()); - }); - }; + const asyncToUpperCase = (input: string) => + delayValue(input.toUpperCase(), interval()); it('works with no timer or rejects', () => { const rebouncedToUpperCase = rebounce(asyncToUpperCase); @@ -167,6 +162,6 @@ describe('rebounce', () => { const rebouncedToUpperCase = rebounce(toUpperCase, 100); expect(rebouncedToUpperCase('a string')).rejects.toStrictEqual(abortError); - rebouncedToUpperCase.clear(); + rebouncedToUpperCase.abort(); }); });