diff --git a/CHANGELOG.md b/CHANGELOG.md index 199f5ab..55e3b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 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 ## 1.0.17 (2023-6-20) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index aa7553d..29ac13f 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -5,6 +5,7 @@ import { writable, StartStopNotifier, readable, + Writable, } from 'svelte/store'; import type { AsyncStoreOptions, @@ -15,6 +16,7 @@ import type { StoresValues, WritableLoadable, VisitedMap, + AsyncLoadable, } from './types.js'; import { anyReloadable, @@ -48,9 +50,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. @@ -58,20 +60,23 @@ 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 => { + // eslint-disable-next-line prefer-const + let thisStore: Writable; + flagStoreCreated(); - const { reloadable, initial, debug } = options; + const { reloadable, initial, debug, rebounceDelay } = options; - const debuggy = debug ? console.log : undefined; + const debuggy = debug ? (...args) => console.log(debug, ...args) : undefined; - const rebouncedMappingLoad = rebounce(mappingLoadFunction); + const rebouncedSelfLoad = rebounce(selfLoadFunction, rebounceDelay); const loadState = writable(getLoadState('LOADING')); const setState = (state: State) => loadState.set(getLoadState(state)); @@ -82,47 +87,81 @@ export const asyncWritable = ( // most recent call of mappingLoadFunction, including resulting side effects // (updating store value, tracking state, etc) - let currentLoadPromise: Promise; - let resolveCurrentLoad: (value: T | PromiseLike) => void; - let rejectCurrentLoad: (reason: Error) => void; + let currentLoadPromise: Promise; + let resolveCurrentLoad: (value: T | PromiseLike | Error) => void; const setCurrentLoadPromise = () => { - currentLoadPromise = new Promise((resolve, reject) => { + debuggy?.('setCurrentLoadPromise -> new load promise generated'); + currentLoadPromise = new Promise((resolve) => { resolveCurrentLoad = resolve; - rejectCurrentLoad = reject; }); }; + const getLoadedValueOrThrow = async (callback?: () => void) => { + debuggy?.('getLoadedValue -> starting await current load'); + const result = await currentLoadPromise; + debuggy?.('getLoadedValue -> got loaded result', result); + callback?.(); + if (result instanceof Error) { + throw result; + } + return currentLoadPromise as T; + }; + let parentValues: StoresValues; - const mappingLoadThenSet = async (setStoreValue) => { + let mostRecentLoadTracker: Record; + const selfLoadThenSet = async () => { if (get(loadState).isSettled) { setCurrentLoadPromise(); debuggy?.('setting RELOADING'); setState('RELOADING'); } + const thisLoadTracker = {}; + mostRecentLoadTracker = thisLoadTracker; + try { - const finalValue = await rebouncedMappingLoad(parentValues); + // parentValues + const finalValue = (await rebouncedSelfLoad(parentValues)) as T; debuggy?.('setting value'); - setStoreValue(finalValue); + thisStore.set(finalValue); + if (!get(loadState).isWriting) { debuggy?.('setting LOADED'); setState('LOADED'); } resolveCurrentLoad(finalValue); - } catch (e) { - if (e.name !== 'AbortError') { - logError(e); + } catch (error) { + debuggy?.('caught error', error); + if (error.name !== 'AbortError') { + logError(error); setState('ERROR'); - rejectCurrentLoad(e); + debuggy?.('resolving current load with error', error); + // Resolve with an Error rather than rejecting so that unhandled rejections + // are not created by the stores 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)); + } else 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)); } } }; - const onFirstSubscription: StartStopNotifier = (setStoreValue) => { + let cleanupSubscriptions: () => void; + + // called when store receives its first subscriber + const onFirstSubscription: StartStopNotifier = () => { setCurrentLoadPromise(); parentValues = getAll(stores); + setState('LOADING'); const initialLoad = async () => { debuggy?.('initial load called'); @@ -131,10 +170,11 @@ export const asyncWritable = ( debuggy?.('setting ready'); ready = true; changeReceived = false; - mappingLoadThenSet(setStoreValue); + selfLoadThenSet(); } catch (error) { - console.log('wtf is happening', error); - rejectCurrentLoad(error); + ready = true; + changeReceived = false; + resolveCurrentLoad(error); } }; initialLoad(); @@ -150,19 +190,21 @@ export const asyncWritable = ( } if (ready) { debuggy?.('proceeding because ready'); - mappingLoadThenSet(setStoreValue); + selfLoadThenSet(); } }) ); // called on losing last subscriber - return () => { + cleanupSubscriptions = () => { parentUnsubscribers.map((unsubscriber) => unsubscriber()); ready = false; + changeReceived = false; }; + cleanupSubscriptions(); }; - const thisStore = writable(initial, onFirstSubscription); + thisStore = writable(initial, onFirstSubscription); const setStoreValueThenWrite = async ( updater: Updater, @@ -171,7 +213,7 @@ export const asyncWritable = ( setState('WRITING'); let oldValue: T; try { - oldValue = await currentLoadPromise; + oldValue = await getLoadedValueOrThrow(); } catch { oldValue = get(thisStore); } @@ -180,9 +222,9 @@ export const asyncWritable = ( let newValue = updater(oldValue); thisStore.set(newValue); - if (mappingWriteFunction && persist) { + if (writePersistFunction && persist) { try { - const writeResponse = (await mappingWriteFunction( + const writeResponse = (await writePersistFunction( newValue, parentValues, oldValue @@ -196,40 +238,47 @@ export const asyncWritable = ( logError(error); debuggy?.('setting ERROR'); setState('ERROR'); - rejectCurrentLoad(error); + resolveCurrentLoad(newValue); throw error; } } + setState('LOADED'); resolveCurrentLoad(newValue); }; // required properties const subscribe = thisStore.subscribe; + const load = () => { const dummyUnsubscribe = thisStore.subscribe(() => { /* no-op */ }); - currentLoadPromise - .catch(() => { - /* no-op */ - }) - .finally(dummyUnsubscribe); - return currentLoadPromise; + return getLoadedValueOrThrow(dummyUnsubscribe); }; + const reload = async (visitedMap?: VisitedMap) => { + const dummyUnsubscribe = thisStore.subscribe(() => { + /* no-op */ + }); ready = false; changeReceived = false; - setCurrentLoadPromise(); + if (get(loadState).isSettled) { + debuggy?.('new load promise'); + setCurrentLoadPromise(); + } debuggy?.('setting RELOADING from reload'); + const wasErrored = get(loadState).isError; setState('RELOADING'); const visitMap = visitedMap ?? new WeakMap(); try { - await reloadAll(stores, visitMap); + parentValues = await reloadAll(stores, visitMap); + debuggy?.('parentValues', parentValues); ready = true; - if (changeReceived || reloadable) { - mappingLoadThenSet(thisStore.set); + debuggy?.(changeReceived, reloadable, wasErrored); + if (changeReceived || reloadable || wasErrored) { + selfLoadThenSet(); } else { resolveCurrentLoad(get(thisStore)); setState('LOADED'); @@ -237,15 +286,32 @@ export const asyncWritable = ( } catch (error) { debuggy?.('caught error during reload'); setState('ERROR'); - rejectCurrentLoad(error); + resolveCurrentLoad(error); } - return currentLoadPromise; + return getLoadedValueOrThrow(dummyUnsubscribe); }; + const set = (newValue: T, persist = true) => setStoreValueThenWrite(() => newValue, persist); const update = (updater: Updater, persist = true) => setStoreValueThenWrite(updater, persist); + const abort = () => { + rebouncedSelfLoad.abort(); + }; + + const reset = getStoreTestingMode() + ? () => { + cleanupSubscriptions(); + thisStore.set(initial); + setState('LOADING'); + ready = false; + changeReceived = false; + currentLoadPromise = undefined; + setCurrentLoadPromise(); + } + : undefined; + return { get store() { return this; @@ -255,7 +321,9 @@ export const asyncWritable = ( reload, set, update, + abort, state: { subscribe: loadState.subscribe }, + ...(reset && { reset }), }; }; @@ -265,7 +333,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. @@ -273,12 +341,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 ); @@ -287,8 +355,9 @@ export const asyncDerived = ( store, subscribe, load, - ...(reload && { reload }), - ...(state && { state }), + reload, + state, + abort, ...(reset && { reset }), }; }; @@ -297,7 +366,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. @@ -305,8 +374,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 4374d7c..957d505 100644 --- a/src/async-stores/types.ts +++ b/src/async-stores/types.ts @@ -16,29 +16,35 @@ 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; - debug?: true; + debug?: string; 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..7cd1521 100644 --- a/src/standard-stores/index.ts +++ b/src/standard-stores/index.ts @@ -12,6 +12,7 @@ import { import { anyReloadable, 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, }; } 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 29eb8e4..f606f39 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -44,21 +44,21 @@ describe('asyncWritable', () => { expect(get(myAsyncReadable)).toBe('expected'); }); - // it('loads initial value when rejected', async () => { - // const myAsyncReadable = asyncReadable('initial', () => - // Promise.reject(new Error('error')) - // ); - // const isInitial = derived( - // myAsyncReadable, - // ($myAsyncReadable) => $myAsyncReadable === 'initial' - // ); - // expect(get(isInitial)).toBe(true); - - // expect(myAsyncReadable.load()).rejects.toStrictEqual(new Error('error')); - // await myAsyncReadable.load().catch(() => Promise.resolve()); - // expect(get(myAsyncReadable)).toBe('initial'); - // expect(get(isInitial)).toBe(true); - // }); + it('loads initial value when rejected', async () => { + const myAsyncReadable = asyncReadable('initial', () => + Promise.reject(new Error('error')) + ); + const isInitial = derived( + myAsyncReadable, + ($myAsyncReadable) => $myAsyncReadable === 'initial' + ); + expect(get(isInitial)).toBe(true); + + expect(myAsyncReadable.load()).rejects.toStrictEqual(new Error('error')); + await myAsyncReadable.load().catch(() => Promise.resolve()); + expect(get(myAsyncReadable)).toBe('initial'); + expect(get(isInitial)).toBe(true); + }); it('does not reload if not reloadable', async () => { const myAsyncDerived = asyncReadable(undefined, mockReload); @@ -100,17 +100,14 @@ describe('asyncWritable', () => { const myAsyncDerived = asyncDerived( writableParent, () => Promise.reject(new Error('error')), - { initial: 'initial', debug: true } + { initial: 'initial' } ); - try { - myAsyncDerived.subscribe(jest.fn); - } catch (error) { - console.log(error); - } + + myAsyncDerived.subscribe(jest.fn); expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); await myAsyncDerived.load().catch(() => Promise.resolve()); - // expect(get(myAsyncDerived)).toBe('initial'); + expect(get(myAsyncDerived)).toBe('initial'); }); it('does not reload if not reloadable', async () => { @@ -195,6 +192,24 @@ describe('asyncWritable', () => { }); it('rejects load 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}`) + ); + + expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); + expect(myAsyncDerived.reload()).rejects.toStrictEqual(new Error('error')); + }); + + it('rejects reload when parent load fails', () => { const asyncReadableParent = asyncReadable(undefined, () => Promise.reject(new Error('error')) ); @@ -247,104 +262,164 @@ describe('asyncWritable', () => { expect(secondDerivedLoad).toHaveBeenCalledTimes(2); }); - // describe('abort/rebounce integration', () => { - // it('loads to rebounced value only', async () => { - // const load = (value: string) => { - // return new Promise((resolve) => - // setTimeout(() => resolve(value), 100) - // ); - // }; - - // const rebouncedLoad = rebounce(load); - // const myParent = writable(); - // const { store: myStore, state: myState } = asyncDerived( - // myParent, - // rebouncedLoad, - // { - // trackState: true, - // } - // ); - - // 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 new Promise((resolve) => setTimeout(resolve, 50)); - // expect(get(myState).isLoading).toBe(true); - // myParent.set('b'); - // await new Promise((resolve) => setTimeout(resolve, 50)); - // expect(get(myState).isLoading).toBe(true); - // myParent.set('c'); - // await new Promise((resolve) => setTimeout(resolve, 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('can be cleared correctly', async () => { - // const load = (value: string) => { - // return new Promise((resolve) => - // setTimeout(() => resolve(value), 100) - // ); - // }; - - // const rebouncedLoad = rebounce(load); - // const myParent = writable(); - // const { store: myStore, state: myState } = asyncDerived( - // myParent, - // rebouncedLoad, - // { - // trackState: true, - // } - // ); - - // myStore.subscribe(jest.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(); - - // 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') - // ); - // }); - // }); + describe('abort/rebounce integration', () => { + it('loads to rebounced value only', async () => { + const load = (value: string) => { + return new Promise((resolve) => + setTimeout(() => resolve(value), 100) + ); + }; + + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived( + myParent, + load, + { + trackState: true, + } + ); + + 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 new Promise((resolve) => setTimeout(resolve, 50)); + expect(get(myState).isLoading).toBe(true); + myParent.set('b'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(get(myState).isLoading).toBe(true); + myParent.set('c'); + await new Promise((resolve) => setTimeout(resolve, 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 Promise.resolve(value); + }; + + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived( + myParent, + load, + { + trackState: true, + rebounceDelay: 100, + } + ); + + 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 new Promise((resolve) => setTimeout(resolve, 50)); + expect(get(myState).isLoading).toBe(true); + myParent.set('b'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(get(myState).isLoading).toBe(true); + myParent.set('c'); + await new Promise((resolve) => setTimeout(resolve, 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('loads last called value instead of last resolved', async () => { + let timesCalled = 0; + const load = (value: string) => { + timesCalled += 1; + return new Promise((resolve) => + setTimeout(() => resolve(value), 200 - timesCalled * 100) + ); + }; + + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived( + myParent, + load, + { + trackState: true, + } + ); + + 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 new Promise((resolve) => setTimeout(resolve, 200)); + expect(get(myStore)).toBe('b'); + expect(setIncorrectly).toBe(false); + expect(get(myState).isLoaded).toBe(true); + }); + + it('can be aborted correctly', async () => { + const load = (value: string) => { + return new Promise((resolve) => + setTimeout(() => resolve(value), 100) + ); + }; + + const myParent = writable(); + const { store: myStore, state: myState } = asyncDerived(myParent, load); + + myStore.subscribe(jest.fn()); + myParent.set('one'); + let loadValue = await myStore.load(); + expect(loadValue).toBe('one'); + + myParent.set('two'); + await new Promise((resolve) => setTimeout(resolve, 50)); + myStore.abort(); + + loadValue = await myStore.load(); + expect(loadValue).toBe('one'); + expect(get(myStore)).toBe('one'); + expect(get(myState).isLoaded).toBe(true); + }); + }); }); describe('multiple parents asyncDerived', () => { @@ -414,6 +489,8 @@ describe('asyncWritable', () => { await myDerived.load(); expect(get(myDerived)).toBe('L: first value'); }); + + // it('calls selfLoad once when multiple ') }); describe('no parents asyncWritable', () => { @@ -595,27 +672,27 @@ describe('asyncWritable', () => { expect(mappingWriteFunction).toHaveBeenCalledTimes(1); }); - // it('still sets value when rejected', async () => { - // const mappingWriteFunction = jest.fn(() => - // Promise.reject(new Error('any')) - // ); - // const myAsyncWritable = asyncWritable( - // writableParent, - // () => Promise.reject(new Error('error')), - // mappingWriteFunction, - // { initial: 'initial' } - // ); - // myAsyncWritable.subscribe(jest.fn); + it('still sets value when rejected', async () => { + const mappingWriteFunction = jest.fn(() => + Promise.reject(new Error('any')) + ); + const myAsyncWritable = asyncWritable( + writableParent, + () => Promise.reject(new Error('error')), + mappingWriteFunction, + { initial: 'initial' } + ); + myAsyncWritable.subscribe(jest.fn); - // expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); - // await myAsyncWritable.load().catch(() => Promise.resolve()); - // expect(get(myAsyncWritable)).toBe('initial'); + expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); + await myAsyncWritable.load().catch(() => Promise.resolve()); + expect(get(myAsyncWritable)).toBe('initial'); - // await myAsyncWritable.set('final').catch(() => Promise.resolve()); - // expect(get(myAsyncWritable)).toBe('final'); + await myAsyncWritable.set('final').catch(() => Promise.resolve()); + expect(get(myAsyncWritable)).toBe('final'); - // expect(mappingWriteFunction).toHaveBeenCalledTimes(1); - // }); + expect(mappingWriteFunction).toHaveBeenCalledTimes(1); + }); it('does not reload if not reloadable', async () => { const myAsyncWritable = asyncWritable(writableParent, mockReload, () => @@ -741,7 +818,7 @@ describe('asyncWritable', () => { 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')) ); @@ -753,43 +830,46 @@ describe('asyncWritable', () => { myAsyncWritable.subscribe(jest.fn); expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); + await safeLoad(myAsyncWritable); }); }); - // describe('error logging', () => { - // afterEach(() => { - // logAsyncErrors(undefined); - // }); + describe('error logging', () => { + afterEach(() => { + logAsyncErrors(undefined); + }); + + it('does not call error logger when no error', async () => { + const errorLogger = jest.fn(); + logAsyncErrors(errorLogger); - // it('does not call error logger when no error', async () => { - // const errorLogger = jest.fn(); - // logAsyncErrors(errorLogger); + const myReadable = asyncReadable(undefined, () => + Promise.resolve('value') + ); + await myReadable.load(); - // const myReadable = asyncReadable(undefined, () => - // Promise.resolve('value') - // ); - // await myReadable.load(); + expect(errorLogger).not.toHaveBeenCalled(); + }); - // expect(errorLogger).not.toHaveBeenCalled(); - // }); + it('does call error logger when async error', async () => { + const errorLogger = jest.fn(); + logAsyncErrors(errorLogger); - // it('does call error logger when async error', async () => { - // const errorLogger = jest.fn(); - // logAsyncErrors(errorLogger); + const myReadable = asyncReadable(undefined, () => + Promise.reject(new Error('error')) + ); - // const myReadable = asyncReadable(undefined, () => - // Promise.reject(new Error('error')) - // ); + myReadable.subscribe(jest.fn()); - // // perform multiple loads and make sure logger only called once - // await safeLoad(myReadable); - // await safeLoad(myReadable); - // await safeLoad(myReadable); + // perform multiple loads and make sure logger only called once + await safeLoad(myReadable); + await safeLoad(myReadable); + await safeLoad(myReadable); - // expect(errorLogger).toHaveBeenCalledWith(new Error('error')); - // expect(errorLogger).toHaveBeenCalledTimes(1); - // }); - // }); + expect(errorLogger).toHaveBeenCalledWith(new Error('error')); + expect(errorLogger).toHaveBeenCalledTimes(1); + }); + }); }); describe('trackState', () => { @@ -897,9 +977,9 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); + const result = await myStore.load(); - expect(get(myStore)).toBe('loaded value'); + expect(result).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -913,9 +993,9 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); + const result = await myStore.load(); - expect(get(myStore)).toBe('loaded value'); + expect(result).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -929,9 +1009,9 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - await myStore.load(); + const result = await myStore.load(); - expect(get(myStore)).toBe('loaded value'); + expect(result).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); }); @@ -1078,6 +1158,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -1120,7 +1202,7 @@ describe('trackState', () => { const { store: myStore, state: myState } = asyncReadable( 'initial', load, - { trackState: true, reloadable: true } + { trackState: true, reloadable: true, debug: 'thing' } ); myStore.subscribe(jest.fn()); @@ -1143,6 +1225,7 @@ describe('trackState', () => { .mockRejectedValueOnce('failure'); const myParent = asyncReadable('initial', parentLoad, { reloadable: true, + debug: 'parent:', }); const { store: myStore, state: myState } = asyncDerived( myParent, @@ -1188,27 +1271,27 @@ describe('trackState', () => { expect(get(myState).isLoaded).toBe(true); }); - // it('tracks writing error', async () => { - // const { store: myStore, state: myState } = asyncWritable( - // [], - // () => Promise.resolve('loaded value'), - // () => Promise.reject(new Error('rejection')), - // { trackState: true } - // ); + it('tracks writing error', async () => { + const { store: myStore, state: myState } = asyncWritable( + [], + () => Promise.resolve('loaded value'), + () => Promise.reject(new Error('rejection')), + { trackState: true } + ); - // expect(get(myState).isLoading).toBe(true); + expect(get(myState).isLoading).toBe(true); - // await myStore.load(); + await myStore.load(); - // expect(get(myState).isLoaded).toBe(true); + expect(get(myState).isLoaded).toBe(true); - // const setPromise = myStore.set('intermediate value'); + const setPromise = myStore.set('intermediate value'); - // expect(get(myState).isWriting).toBe(true); + expect(get(myState).isWriting).toBe(true); - // await setPromise.catch(jest.fn()); + await setPromise.catch(jest.fn()); - // expect(get(myState).isError).toBe(true); - // }); + expect(get(myState).isError).toBe(true); + }); }); }); diff --git a/test/testing.test.ts b/test/testing.test.ts index 06222a1..73d978b 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -15,7 +15,13 @@ import { enableStoreTestingMode(); const mockedFetch = jest.fn(); -const myReadable = asyncReadable('initial', () => mockedFetch()); +const { store: myReadable, state: myState } = asyncReadable( + 'initial', + () => mockedFetch(), + { + debug: 'myReadable:', + } +); beforeEach(() => { myReadable.reset(); @@ -23,6 +29,7 @@ beforeEach(() => { describe('can be reset for different tests', () => { it('loads resolution', async () => { + myReadable.subscribe(jest.fn()); mockedFetch.mockResolvedValueOnce('loaded'); await myReadable.load(); @@ -35,15 +42,17 @@ describe('can be reset for different tests', () => { }); it('loads rejection', async () => { + console.log('starting failed test'); + // myReadable.subscribe(jest.fn()); mockedFetch.mockRejectedValueOnce('rejected'); await myReadable.load().catch(() => Promise.resolve()); expect(get(myReadable)).toBe('initial'); - mockedFetch.mockResolvedValueOnce('loaded'); - await myReadable.load().catch(() => Promise.resolve()); + // mockedFetch.mockResolvedValueOnce('loaded'); + // await myReadable.load().catch(() => Promise.resolve()); - expect(get(myReadable)).toBe('initial'); + // expect(get(myReadable)).toBe('initial'); }); }); diff --git a/test/utils/index.test.ts b/test/utils/index.test.ts index 3a4463b..1596b0f 100644 --- a/test/utils/index.test.ts +++ b/test/utils/index.test.ts @@ -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 @@ -167,6 +167,6 @@ describe('rebounce', () => { const rebouncedToUpperCase = rebounce(toUpperCase, 100); expect(rebouncedToUpperCase('a string')).rejects.toStrictEqual(abortError); - rebouncedToUpperCase.clear(); + rebouncedToUpperCase.abort(); }); });