From fed2e2730ad261a20a20a745cee654252d86c154 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 18 Jul 2023 13:44:10 -0400 Subject: [PATCH 01/13] working --- src/async-stores/index-copy.ts | 310 +++++++++++++++++++++++++++++++++ src/async-stores/index.ts | 193 +++----------------- 2 files changed, 330 insertions(+), 173 deletions(-) create mode 100644 src/async-stores/index-copy.ts diff --git a/src/async-stores/index-copy.ts b/src/async-stores/index-copy.ts new file mode 100644 index 0000000..e1f124a --- /dev/null +++ b/src/async-stores/index-copy.ts @@ -0,0 +1,310 @@ +import { get, type Updater, type Readable, writable } from 'svelte/store'; +import type { + AsyncStoreOptions, + Loadable, + LoadState, + State, + Stores, + StoresValues, + WritableLoadable, + VisitedMap, +} from './types.js'; +import { + anyReloadable, + getStoresArray, + reloadAll, + loadAll, +} from '../utils/index.js'; +import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; + +// STORES + +const getLoadState = (stateString: State): LoadState => { + return { + isLoading: stateString === 'LOADING', + isReloading: stateString === 'RELOADING', + isLoaded: stateString === 'LOADED', + isWriting: stateString === 'WRITING', + isError: stateString === 'ERROR', + isPending: stateString === 'LOADING' || stateString === 'RELOADING', + isSettled: stateString === 'LOADED' || stateString === 'ERROR', + }; +}; + +/** + * Generate a Loadable store that is considered 'loaded' after resolving synchronous or asynchronous behavior. + * This behavior may be derived from the value of parent Loadable or non Loadable stores. + * If so, this store will begin loading only after the parents have loaded. + * This store is also writable. It includes a `set` function that will immediately update the value of the store + * 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 + * 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. + * 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. + * The loaded value of the store will be ready after awaiting the load function of this store. + */ +export const asyncWritable = ( + stores: S, + mappingLoadFunction: (values: StoresValues) => Promise | T, + mappingWriteFunction?: ( + value: T, + parentValues?: StoresValues, + oldValue?: T + ) => Promise, + options: AsyncStoreOptions = {} +): WritableLoadable => { + flagStoreCreated(); + const { reloadable, trackState, initial } = options; + + const loadState = trackState + ? writable(getLoadState('LOADING')) + : undefined; + + const setState = (state: State) => loadState?.set(getLoadState(state)); + + // stringified representation of parents' loaded values + // used to track whether a change has occurred and the store reloaded + let loadedValuesString: string; + + let latestLoadAndSet: () => Promise; + + // most recent call of mappingLoadFunction, including resulting side effects + // (updating store value, tracking state, etc) + let currentLoadPromise: Promise; + + const tryLoad = async (values: StoresValues) => { + try { + return await mappingLoadFunction(values); + } catch (e) { + if (e.name !== 'AbortError') { + logError(e); + setState('ERROR'); + } + throw e; + } + }; + + // 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()); + }; + }); + + loadDependenciesThenSet = async ( + parentLoadFunction: (stores: S) => Promise>, + forceReload = false + ) => { + const loadParentStores = parentLoadFunction(stores); + + try { + await loadParentStores; + } catch { + currentLoadPromise = loadParentStores as Promise; + setState('ERROR'); + return currentLoadPromise; + } + + const storeValues = getStoresArray(stores).map((store) => + get(store) + ) as StoresValues; + + if (!forceReload) { + const newValuesString = JSON.stringify(storeValues); + if (newValuesString === loadedValuesString) { + // no change, don't generate new promise + return currentLoadPromise; + } + loadedValuesString = newValuesString; + } + + // convert storeValues to single store value if expected by mapping function + const loadInput = Array.isArray(stores) ? storeValues : storeValues[0]; + + const loadAndSet = async () => { + latestLoadAndSet = loadAndSet; + if (get(loadState)?.isSettled) { + setState('RELOADING'); + } + 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); + } + throw e; + } + }; + + currentLoadPromise = loadAndSet(); + return currentLoadPromise; + }; + + const setStoreValueThenWrite = async ( + updater: Updater, + persist?: boolean + ) => { + setState('WRITING'); + let oldValue: T; + try { + oldValue = await loadDependenciesThenSet(loadAll); + } catch { + oldValue = get(thisStore); + } + const newValue = updater(oldValue); + currentLoadPromise = currentLoadPromise + .then(() => newValue) + .catch(() => newValue); + thisStore.set(newValue); + + if (mappingWriteFunction && persist) { + try { + const parentValues = await loadAll(stores); + + const writeResponse = (await mappingWriteFunction( + newValue, + parentValues, + oldValue + )) as T; + + if (writeResponse !== undefined) { + thisStore.set(writeResponse); + currentLoadPromise = currentLoadPromise.then(() => writeResponse); + } + } catch (e) { + logError(e); + setState('ERROR'); + throw e; + } + } + setState('LOADED'); + }; + + // required properties + const subscribe = thisStore.subscribe; + 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 reset = getStoreTestingMode() + ? () => { + thisStore.set(initial); + setState('LOADING'); + loadedValuesString = undefined; + currentLoadPromise = undefined; + } + : undefined; + + return { + get store() { + return this; + }, + subscribe, + set, + update, + load, + ...(reload && { reload }), + ...(state && { state }), + ...(reset && { reset }), + }; +}; + +/** + * Generate a Loadable store that is considered 'loaded' after resolving asynchronous behavior. + * This asynchronous behavior may be derived from the value of parent Loadable or non Loadable stores. + * 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 + * 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. + * The loaded value of the store will be ready after awaiting the load function of this store. + */ +export const asyncDerived = ( + stores: S, + mappingLoadFunction: (values: StoresValues) => Promise, + options?: AsyncStoreOptions +): Loadable => { + const { store, subscribe, load, reload, state, reset } = asyncWritable( + stores, + mappingLoadFunction, + undefined, + options + ); + + return { + store, + subscribe, + load, + ...(reload && { reload }), + ...(state && { state }), + ...(reset && { reset }), + }; +}; + +/** + * 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 + * 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. + * The loaded value of the store will be ready after awaiting the load function of this store. + */ +export const asyncReadable = ( + initial: T, + loadFunction: () => Promise, + options?: Omit, 'initial'> +): Loadable => { + return asyncDerived([], loadFunction, { ...options, initial }); +}; diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index e1f124a..849fd16 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -1,4 +1,10 @@ -import { get, type Updater, type Readable, writable } from 'svelte/store'; +import { + get, + type Updater, + type Readable, + writable, + StartStopNotifier, +} from 'svelte/store'; import type { AsyncStoreOptions, Loadable, @@ -63,20 +69,18 @@ export const asyncWritable = ( const loadState = trackState ? writable(getLoadState('LOADING')) : undefined; - const setState = (state: State) => loadState?.set(getLoadState(state)); - // stringified representation of parents' loaded values - // used to track whether a change has occurred and the store reloaded - let loadedValuesString: string; - - let latestLoadAndSet: () => Promise; + // flag marking whether store is ready for updates from subscriptions + let ready = false; // most recent call of mappingLoadFunction, including resulting side effects // (updating store value, tracking state, etc) let currentLoadPromise: Promise; - const tryLoad = async (values: StoresValues) => { + let latestLoadAndSet: () => Promise; + + const tryLoadValue = async (values: StoresValues) => { try { return await mappingLoadFunction(values); } catch (e) { @@ -88,172 +92,15 @@ export const asyncWritable = ( } }; - // 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()); - }; - }); - - loadDependenciesThenSet = async ( - parentLoadFunction: (stores: S) => Promise>, - forceReload = false - ) => { - const loadParentStores = parentLoadFunction(stores); - - try { - await loadParentStores; - } catch { - currentLoadPromise = loadParentStores as Promise; - setState('ERROR'); - return currentLoadPromise; - } - - const storeValues = getStoresArray(stores).map((store) => - get(store) - ) as StoresValues; - - if (!forceReload) { - const newValuesString = JSON.stringify(storeValues); - if (newValuesString === loadedValuesString) { - // no change, don't generate new promise - return currentLoadPromise; - } - loadedValuesString = newValuesString; - } - - // convert storeValues to single store value if expected by mapping function - const loadInput = Array.isArray(stores) ? storeValues : storeValues[0]; - - const loadAndSet = async () => { - latestLoadAndSet = loadAndSet; - if (get(loadState)?.isSettled) { - setState('RELOADING'); - } - 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); - } - throw e; - } - }; - - currentLoadPromise = loadAndSet(); - return currentLoadPromise; - }; - - const setStoreValueThenWrite = async ( - updater: Updater, - persist?: boolean - ) => { - setState('WRITING'); - let oldValue: T; - try { - oldValue = await loadDependenciesThenSet(loadAll); - } catch { - oldValue = get(thisStore); - } - const newValue = updater(oldValue); - currentLoadPromise = currentLoadPromise - .then(() => newValue) - .catch(() => newValue); - thisStore.set(newValue); - - if (mappingWriteFunction && persist) { - try { - const parentValues = await loadAll(stores); - - const writeResponse = (await mappingWriteFunction( - newValue, - parentValues, - oldValue - )) as T; - - if (writeResponse !== undefined) { - thisStore.set(writeResponse); - currentLoadPromise = currentLoadPromise.then(() => writeResponse); - } - } catch (e) { - logError(e); - setState('ERROR'); - throw e; - } - } - setState('LOADED'); - }; - - // required properties - const subscribe = thisStore.subscribe; - 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 reset = getStoreTestingMode() - ? () => { - thisStore.set(initial); - setState('LOADING'); - loadedValuesString = undefined; - currentLoadPromise = undefined; - } - : undefined; + const loadThenSet = async (set) => {}; - return { - get store() { - return this; - }, - subscribe, - set, - update, - load, - ...(reload && { reload }), - ...(state && { state }), - ...(reset && { reset }), + const onFirstSubscribtion: StartStopNotifier = async (set) => { + (async () => { + const values = await loadAll(stores); + ready = true; + tryLoadValue(values); + })(); + const values = await loadAll(); }; }; From b80bf0898f96049f7f594dc28e9368a564d1dd3f Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Wed, 19 Jul 2023 16:53:49 -0400 Subject: [PATCH 02/13] sort of working --- src/async-stores/index.ts | 122 ++++++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index 849fd16..cc67cc6 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -4,6 +4,7 @@ import { type Readable, writable, StartStopNotifier, + readable, } from 'svelte/store'; import type { AsyncStoreOptions, @@ -20,6 +21,7 @@ import { getStoresArray, reloadAll, loadAll, + rebounce, } from '../utils/index.js'; import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; @@ -66,44 +68,128 @@ export const asyncWritable = ( flagStoreCreated(); const { reloadable, trackState, initial } = options; - const loadState = trackState - ? writable(getLoadState('LOADING')) - : undefined; - const setState = (state: State) => loadState?.set(getLoadState(state)); + const rebouncedMappingLoad = rebounce(mappingLoadFunction); + + const loadState = writable(getLoadState('LOADING')); + const setState = (state: State) => loadState.set(getLoadState(state)); // 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 resolveCurrentLoad: (value: T | PromiseLike) => void; + let rejectCurrentLoad: (reason: Error) => void; + + const setCurrentLoadPromise = () => { + currentLoadPromise = new Promise((resolve, reject) => { + resolveCurrentLoad = resolve; + rejectCurrentLoad = reject; + }); + }; - let latestLoadAndSet: () => Promise; + let parentValues: StoresValues; + + const mappingLoadThenSet = async (setStoreValue) => { + if (get(loadState).isSettled) { + setCurrentLoadPromise(); + setState('RELOADING'); + } - const tryLoadValue = async (values: StoresValues) => { try { - return await mappingLoadFunction(values); + const finalValue = await rebouncedMappingLoad(parentValues); + setStoreValue(finalValue); + resolveCurrentLoad(finalValue); + setState('LOADED'); } catch (e) { if (e.name !== 'AbortError') { - logError(e); setState('ERROR'); + rejectCurrentLoad(e); } - throw e; } }; - const loadThenSet = async (set) => {}; + const onFirstSubscription: StartStopNotifier = (setStoreValue) => { + setCurrentLoadPromise(); - const onFirstSubscribtion: StartStopNotifier = async (set) => { - (async () => { - const values = await loadAll(stores); - ready = true; - tryLoadValue(values); - })(); - const values = await loadAll(); + const initialLoad = async () => { + try { + parentValues = await loadAll(stores); + ready = true; + changeReceived = false; + mappingLoadThenSet(setStoreValue); + } catch (error) { + rejectCurrentLoad(error); + } + }; + initialLoad(); + + const parentUnsubscribers = getStoresArray(stores).map((store, i) => + store.subscribe((value) => { + changeReceived = true; + if (Array.isArray(stores)) { + parentValues[i] = value; + } else { + parentValues = value as any; + } + if (ready) { + mappingLoadThenSet(setStoreValue); + } + }) + ); + + // called on losing last subscriber + return () => { + parentUnsubscribers.map((unsubscriber) => unsubscriber()); + }; + }; + + const thisStore = writable(initial, onFirstSubscription); + + const subscribe = thisStore.subscribe; + const load = async () => { + const dummyUnsubscribe = thisStore.subscribe(() => { + /* no-op */ + }); + try { + const result = await currentLoadPromise; + dummyUnsubscribe(); + return result; + } catch (error) { + dummyUnsubscribe(); + throw error; + } + }; + const reload = async (visitedMap?: VisitedMap) => { + ready = false; + changeReceived = false; + setCurrentLoadPromise(); + setState('RELOADING'); + + const visitMap = visitedMap ?? new WeakMap(); + await reloadAll(stores, visitMap); + ready = true; + if (changeReceived || reloadable) { + mappingLoadThenSet(thisStore.set); + } else { + resolveCurrentLoad(get(thisStore)); + } + return currentLoadPromise; }; -}; + return { + get store() { + return this; + }, + subscribe, + load, + reload, + set: () => Promise.resolve(), + update: () => Promise.resolve(), + }; +}; /** * Generate a Loadable store that is considered 'loaded' after resolving asynchronous behavior. * This asynchronous behavior may be derived from the value of parent Loadable or non Loadable stores. From 83820aa76bfd142d4333f28dd02ae72c95191e61 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 25 Jul 2023 15:35:41 -0400 Subject: [PATCH 03/13] mostly working except for rejections --- CHANGELOG.md | 8 +- src/async-stores/index.ts | 109 ++++++-- src/async-stores/types.ts | 1 + test/async-stores/index.test.ts | 475 +++++++++++++++++++------------- 4 files changed, 387 insertions(+), 206 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e7799..199f5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 + ## 1.0.17 (2023-6-20) - *BREAKING CHANGE* chore: rearrange dependencies to minimize installed package size @@ -8,7 +14,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 cc67cc6..aa7553d 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -22,6 +22,7 @@ import { reloadAll, loadAll, rebounce, + getAll, } from '../utils/index.js'; import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; @@ -66,7 +67,9 @@ export const asyncWritable = ( options: AsyncStoreOptions = {} ): WritableLoadable => { flagStoreCreated(); - const { reloadable, trackState, initial } = options; + const { reloadable, initial, debug } = options; + + const debuggy = debug ? console.log : undefined; const rebouncedMappingLoad = rebounce(mappingLoadFunction); @@ -95,16 +98,22 @@ export const asyncWritable = ( const mappingLoadThenSet = async (setStoreValue) => { if (get(loadState).isSettled) { setCurrentLoadPromise(); + debuggy?.('setting RELOADING'); setState('RELOADING'); } try { const finalValue = await rebouncedMappingLoad(parentValues); + debuggy?.('setting value'); setStoreValue(finalValue); + if (!get(loadState).isWriting) { + debuggy?.('setting LOADED'); + setState('LOADED'); + } resolveCurrentLoad(finalValue); - setState('LOADED'); } catch (e) { if (e.name !== 'AbortError') { + logError(e); setState('ERROR'); rejectCurrentLoad(e); } @@ -113,14 +122,18 @@ export const asyncWritable = ( const onFirstSubscription: StartStopNotifier = (setStoreValue) => { setCurrentLoadPromise(); + parentValues = getAll(stores); const initialLoad = async () => { + debuggy?.('initial load called'); try { parentValues = await loadAll(stores); + debuggy?.('setting ready'); ready = true; changeReceived = false; mappingLoadThenSet(setStoreValue); } catch (error) { + console.log('wtf is happening', error); rejectCurrentLoad(error); } }; @@ -128,13 +141,15 @@ export const asyncWritable = ( const parentUnsubscribers = getStoresArray(stores).map((store, i) => store.subscribe((value) => { + debuggy?.('received value', value); changeReceived = true; if (Array.isArray(stores)) { parentValues[i] = value; } else { - parentValues = value as any; + parentValues = value as StoresValues; } if (ready) { + debuggy?.('proceeding because ready'); mappingLoadThenSet(setStoreValue); } }) @@ -143,41 +158,93 @@ export const asyncWritable = ( // called on losing last subscriber return () => { parentUnsubscribers.map((unsubscriber) => unsubscriber()); + ready = false; }; }; const thisStore = writable(initial, onFirstSubscription); + const setStoreValueThenWrite = async ( + updater: Updater, + persist?: boolean + ) => { + setState('WRITING'); + let oldValue: T; + try { + oldValue = await currentLoadPromise; + } catch { + oldValue = get(thisStore); + } + + setCurrentLoadPromise(); + let newValue = updater(oldValue); + thisStore.set(newValue); + + if (mappingWriteFunction && persist) { + try { + const writeResponse = (await mappingWriteFunction( + newValue, + parentValues, + oldValue + )) as T; + + if (writeResponse !== undefined) { + thisStore.set(writeResponse); + newValue = writeResponse; + } + } catch (error) { + logError(error); + debuggy?.('setting ERROR'); + setState('ERROR'); + rejectCurrentLoad(error); + throw error; + } + } + setState('LOADED'); + resolveCurrentLoad(newValue); + }; + + // required properties const subscribe = thisStore.subscribe; - const load = async () => { + const load = () => { const dummyUnsubscribe = thisStore.subscribe(() => { /* no-op */ }); - try { - const result = await currentLoadPromise; - dummyUnsubscribe(); - return result; - } catch (error) { - dummyUnsubscribe(); - throw error; - } + currentLoadPromise + .catch(() => { + /* no-op */ + }) + .finally(dummyUnsubscribe); + return currentLoadPromise; }; const reload = async (visitedMap?: VisitedMap) => { ready = false; changeReceived = false; setCurrentLoadPromise(); + debuggy?.('setting RELOADING from reload'); setState('RELOADING'); const visitMap = visitedMap ?? new WeakMap(); - await reloadAll(stores, visitMap); - ready = true; - if (changeReceived || reloadable) { - mappingLoadThenSet(thisStore.set); - } else { - resolveCurrentLoad(get(thisStore)); + try { + await reloadAll(stores, visitMap); + ready = true; + if (changeReceived || reloadable) { + mappingLoadThenSet(thisStore.set); + } else { + resolveCurrentLoad(get(thisStore)); + setState('LOADED'); + } + } catch (error) { + debuggy?.('caught error during reload'); + setState('ERROR'); + rejectCurrentLoad(error); } return currentLoadPromise; }; + const set = (newValue: T, persist = true) => + setStoreValueThenWrite(() => newValue, persist); + const update = (updater: Updater, persist = true) => + setStoreValueThenWrite(updater, persist); return { get store() { @@ -186,10 +253,12 @@ export const asyncWritable = ( subscribe, load, reload, - set: () => Promise.resolve(), - update: () => Promise.resolve(), + set, + update, + state: { subscribe: loadState.subscribe }, }; }; + /** * Generate a Loadable store that is considered 'loaded' after resolving asynchronous behavior. * This asynchronous behavior may be derived from the value of parent Loadable or non Loadable stores. diff --git a/src/async-stores/types.ts b/src/async-stores/types.ts index dd60eff..4374d7c 100644 --- a/src/async-stores/types.ts +++ b/src/async-stores/types.ts @@ -37,6 +37,7 @@ export type WritableLoadable = Loadable & AsyncWritable; export interface AsyncStoreOptions { reloadable?: true; trackState?: true; + debug?: true; initial?: T; } export declare type StoresArray = diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index e9c7666..0aa0350 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -44,28 +44,30 @@ 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('does not reload if not reloadable', () => { + // 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); 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 () => { @@ -83,7 +85,7 @@ describe('asyncWritable', () => { }); describe('one parent asyncDerived', () => { - it('loads expected value', async () => { + it('loads expected value NOMERGE', async () => { const myAsyncDerived = asyncDerived(writableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); @@ -98,21 +100,27 @@ describe('asyncWritable', () => { const myAsyncDerived = asyncDerived( writableParent, () => Promise.reject(new Error('error')), - { initial: 'initial' } + { initial: 'initial', debug: true } ); - myAsyncDerived.subscribe(vi.fn()); + try { + myAsyncDerived.subscribe(vi.fn); + } catch (error) { + console.log(error); + } 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', () => { + 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 +147,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 () => { @@ -188,10 +198,19 @@ describe('asyncWritable', () => { const asyncReadableParent = asyncReadable(undefined, () => Promise.reject(new Error('error')) ); + expect(asyncReadableParent.load()).rejects.toStrictEqual( + new Error('error') + ); + const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); +<<<<<<< HEAD myAsyncDerived.subscribe(vi.fn()); +||||||| parent of 7cc4482 (mostly working except for rejections) + myAsyncDerived.subscribe(jest.fn); +======= +>>>>>>> 7cc4482 (mostly working except for rejections) expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); }); @@ -216,7 +235,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 new Promise((resolve) => setTimeout(resolve, 100)); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('initial second'); @@ -234,104 +253,110 @@ 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, - } - ); - + // 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, + // } + // ); + +<<<<<<< HEAD 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(); - - 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') - ); - }); - }); +||||||| parent of 7cc4482 (mostly working except for rejections) + myStore.subscribe(jest.fn()); +======= + // myStore.subscribe(jest.fn()); +>>>>>>> 7cc4482 (mostly working except for rejections) + + // 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('multiple parents asyncDerived', () => { @@ -355,6 +380,7 @@ describe('asyncWritable', () => { expect(get(myAsyncDerived)).toBe( 'derived from writable, loadable, first value' ); + writableParent.set('new value'); await myAsyncDerived.load(); expect(get(myAsyncDerived)).toBe( @@ -371,12 +397,26 @@ describe('asyncWritable', () => { undefined, () => new Promise((resolve) => setTimeout(resolve, 1000)) ); + const mockLoad = jest + .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}`; + } ); +<<<<<<< HEAD myDerived.subscribe(vi.fn()); +||||||| parent of 7cc4482 (mostly working except for rejections) + myDerived.subscribe(jest.fn); +======= + myDerived.subscribe(jest.fn()); +>>>>>>> 7cc4482 (mostly working except for rejections) writableParent.set('A'); writableParent.set('B'); writableParent.set('C'); @@ -573,6 +613,7 @@ describe('asyncWritable', () => { expect(mappingWriteFunction).toHaveBeenCalledTimes(1); }); +<<<<<<< HEAD it('still sets value when rejected', async () => { const mappingWriteFunction = vi.fn(() => Promise.reject(new Error('any')) @@ -584,25 +625,52 @@ describe('asyncWritable', () => { { initial: 'initial' } ); myAsyncWritable.subscribe(vi.fn()); - - 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'); - - expect(mappingWriteFunction).toHaveBeenCalledTimes(1); - }); - - it('does not reload if not reloadable', () => { +||||||| parent of 7cc4482 (mostly working except for rejections) + 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); +>>>>>>> 7cc4482 (mostly working except for rejections) + + // 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'); + + // expect(mappingWriteFunction).toHaveBeenCalledTimes(1); + // }); + + 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(); + let value = await myAsyncWritable.load(); + expect(value).toBe('first value'); + value = await myAsyncWritable.reload(); + expect(value).toBe('first value'); }); it('does reload if reloadable', async () => { @@ -634,7 +702,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 +711,10 @@ describe('asyncWritable', () => { ); myAsyncWritable.subscribe(vi.fn()); - expect(myAsyncWritable.load()).resolves.toBe('derived from first value'); - expect(isReloadable(myAsyncWritable)).toBeFalsy(); + let value = await myAsyncWritable.load(); + expect(value).toBe('derived from first value'); + value = await myAsyncWritable.reload(); + expect(value).toBe('derived from first value'); }); it('can access asyncReadable parent loaded value while writing', async () => { @@ -730,40 +800,60 @@ describe('asyncWritable', () => { }); }); - describe('error logging', () => { - afterEach(() => { - logAsyncErrors(undefined); - }); + // describe('error logging', () => { + // afterEach(() => { + // logAsyncErrors(undefined); + // }); +<<<<<<< HEAD it('does not call error logger when no error', async () => { const errorLogger = vi.fn(); logAsyncErrors(errorLogger); +||||||| parent of 7cc4482 (mostly working except for rejections) + 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); +>>>>>>> 7cc4482 (mostly working except for rejections) - 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(); + // }); +<<<<<<< HEAD it('does call error logger when async error', async () => { const errorLogger = vi.fn(); logAsyncErrors(errorLogger); - - const myReadable = asyncReadable(undefined, () => - Promise.reject(new Error('error')) - ); - - // 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); - }); - }); +||||||| parent of 7cc4482 (mostly working except for rejections) + 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); +>>>>>>> 7cc4482 (mostly working except for rejections) + + // const myReadable = asyncReadable(undefined, () => + // Promise.reject(new Error('error')) + // ); + + // // 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); + // }); + // }); }); describe('trackState', () => { @@ -947,6 +1037,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -976,6 +1068,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -1092,6 +1186,9 @@ describe('trackState', () => { load, { trackState: true, reloadable: true } ); + + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await safeLoad(myStore); @@ -1117,6 +1214,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await safeLoad(myStore); @@ -1153,27 +1252,33 @@ 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); +<<<<<<< HEAD await setPromise.catch(vi.fn()); - - expect(get(myState).isError).toBe(true); - }); +||||||| parent of 7cc4482 (mostly working except for rejections) + await setPromise.catch(jest.fn()); +======= + // await setPromise.catch(jest.fn()); +>>>>>>> 7cc4482 (mostly working except for rejections) + + // expect(get(myState).isError).toBe(true); + // }); }); }); From 55830058afbd16b6919b440bd09b8440082d7613 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Mon, 31 Jul 2023 13:07:23 -0400 Subject: [PATCH 04/13] broke something --- CHANGELOG.md | 1 + src/async-stores/index.ts | 175 ++++++++---- src/async-stores/types.ts | 16 +- src/persisted/types.ts | 7 +- src/standard-stores/index.ts | 21 +- src/utils/index.ts | 11 +- test/async-stores/index.test.ts | 473 +++++++++++++++++++++----------- test/testing.test.ts | 17 +- test/utils/index.test.ts | 4 +- 9 files changed, 478 insertions(+), 247 deletions(-) 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 0aa0350..e8cd526 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); + 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); - // }); + 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(vi.fn); - } catch (error) { - console.log(error); - } + + myAsyncDerived.subscribe(vi.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')) ); @@ -253,74 +268,78 @@ 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, - // } - // ); + 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; + } + }); +<<<<<<< HEAD <<<<<<< HEAD myStore.subscribe(vi.fn()); ||||||| parent of 7cc4482 (mostly working except for rejections) @@ -328,35 +347,97 @@ describe('asyncWritable', () => { ======= // myStore.subscribe(jest.fn()); >>>>>>> 7cc4482 (mostly working except for rejections) +||||||| parent of b2be866 (broke something) + // myStore.subscribe(jest.fn()); +======= + let everErrored = false; + myState.subscribe((state) => { + if (state.isError) { + everErrored = true; + } + }); +>>>>>>> b2be866 (broke something) + + 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) + ); + }; - // 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') - // ); - // }); - // }); + 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', () => { @@ -432,6 +513,8 @@ describe('asyncWritable', () => { await myDerived.load(); expect(get(myDerived)).toBe('L: first value'); }); + + // it('calls selfLoad once when multiple ') }); describe('no parents asyncWritable', () => { @@ -613,6 +696,7 @@ describe('asyncWritable', () => { expect(mappingWriteFunction).toHaveBeenCalledTimes(1); }); +<<<<<<< HEAD <<<<<<< HEAD it('still sets value when rejected', async () => { const mappingWriteFunction = vi.fn(() => @@ -650,16 +734,41 @@ describe('asyncWritable', () => { // ); // myAsyncWritable.subscribe(jest.fn); >>>>>>> 7cc4482 (mostly working except for rejections) +||||||| parent of b2be866 (broke something) + // 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); +>>>>>>> b2be866 (broke something) - // 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, () => @@ -785,7 +894,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')) ); @@ -797,14 +906,16 @@ describe('asyncWritable', () => { myAsyncWritable.subscribe(vi.fn()); expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); + await safeLoad(myAsyncWritable); }); }); - // describe('error logging', () => { - // afterEach(() => { - // logAsyncErrors(undefined); - // }); + describe('error logging', () => { + afterEach(() => { + logAsyncErrors(undefined); + }); +<<<<<<< HEAD <<<<<<< HEAD it('does not call error logger when no error', async () => { const errorLogger = vi.fn(); @@ -818,15 +929,25 @@ describe('asyncWritable', () => { // const errorLogger = jest.fn(); // logAsyncErrors(errorLogger); >>>>>>> 7cc4482 (mostly working except for rejections) +||||||| parent of b2be866 (broke something) + // 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); +>>>>>>> b2be866 (broke something) - // 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(); + }); +<<<<<<< HEAD <<<<<<< HEAD it('does call error logger when async error', async () => { const errorLogger = vi.fn(); @@ -840,20 +961,31 @@ describe('asyncWritable', () => { // const errorLogger = jest.fn(); // logAsyncErrors(errorLogger); >>>>>>> 7cc4482 (mostly working except for rejections) +||||||| parent of b2be866 (broke something) + // 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); +>>>>>>> b2be866 (broke something) + + 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', () => { @@ -961,9 +1093,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); }); @@ -977,9 +1109,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); }); @@ -993,9 +1125,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); }); }); @@ -1142,6 +1274,8 @@ describe('trackState', () => { { trackState: true } ); + myStore.subscribe(jest.fn()); + expect(get(myState).isLoading).toBe(true); await myStore.load(); @@ -1184,7 +1318,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()); @@ -1207,6 +1341,7 @@ describe('trackState', () => { .mockRejectedValueOnce('failure'); const myParent = asyncReadable('initial', parentLoad, { reloadable: true, + debug: 'parent:', }); const { store: myStore, state: myState } = asyncDerived( myParent, @@ -1252,24 +1387,25 @@ 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); +<<<<<<< HEAD <<<<<<< HEAD await setPromise.catch(vi.fn()); ||||||| parent of 7cc4482 (mostly working except for rejections) @@ -1277,8 +1413,13 @@ describe('trackState', () => { ======= // await setPromise.catch(jest.fn()); >>>>>>> 7cc4482 (mostly working except for rejections) +||||||| parent of b2be866 (broke something) + // await setPromise.catch(jest.fn()); +======= + await setPromise.catch(jest.fn()); +>>>>>>> b2be866 (broke something) - // 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 ef7de6c..2e4a135 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -15,7 +15,13 @@ import { enableStoreTestingMode(); const mockedFetch = vi.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(vi.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 7594537..4989a8d 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(); }); }); From 3f4da8bdd36a7c739b86e6e7e3f61a26b2af1f41 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 1 Aug 2023 16:50:29 -0400 Subject: [PATCH 05/13] almost fixed --- src/async-stores/index.ts | 17 ++-- test/async-stores/index.test.ts | 146 ++------------------------------ test/testing.test.ts | 8 +- test/utils/index.test.ts | 29 ++++--- 4 files changed, 42 insertions(+), 158 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index 29ac13f..81ac9a9 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -159,6 +159,7 @@ export const asyncWritable = ( // called when store receives its first subscriber const onFirstSubscription: StartStopNotifier = () => { + debuggy?.('onFirstSubscription'); setCurrentLoadPromise(); parentValues = getAll(stores); setState('LOADING'); @@ -195,13 +196,18 @@ export const asyncWritable = ( }) ); - // called on losing last subscriber cleanupSubscriptions = () => { + debuggy?.('cleaning up subscriptions'); parentUnsubscribers.map((unsubscriber) => unsubscriber()); ready = false; changeReceived = false; }; - cleanupSubscriptions(); + + // called on losing last subscriber + return () => { + debuggy?.('stopping store'); + cleanupSubscriptions(); + }; }; thisStore = writable(initial, onFirstSubscription); @@ -302,13 +308,14 @@ export const asyncWritable = ( const reset = getStoreTestingMode() ? () => { - cleanupSubscriptions(); + // cleanupSubscriptions?.(); thisStore.set(initial); setState('LOADING'); ready = false; changeReceived = false; - currentLoadPromise = undefined; - setCurrentLoadPromise(); + // currentLoadPromise = undefined; + // setCurrentLoadPromise(); + // rebouncedSelfLoad.abort(); } : undefined; diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index e8cd526..add7226 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -103,7 +103,7 @@ describe('asyncWritable', () => { { initial: 'initial' } ); - myAsyncDerived.subscribe(vi.fn); + myAsyncDerived.subscribe(vi.fn()); expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); await myAsyncDerived.load().catch(() => Promise.resolve()); @@ -220,12 +220,6 @@ describe('asyncWritable', () => { const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); -<<<<<<< HEAD - myAsyncDerived.subscribe(vi.fn()); -||||||| parent of 7cc4482 (mostly working except for rejections) - myAsyncDerived.subscribe(jest.fn); -======= ->>>>>>> 7cc4482 (mostly working except for rejections) expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); }); @@ -339,24 +333,12 @@ describe('asyncWritable', () => { } }); -<<<<<<< HEAD -<<<<<<< HEAD - myStore.subscribe(vi.fn()); -||||||| parent of 7cc4482 (mostly working except for rejections) - myStore.subscribe(jest.fn()); -======= - // myStore.subscribe(jest.fn()); ->>>>>>> 7cc4482 (mostly working except for rejections) -||||||| parent of b2be866 (broke something) - // myStore.subscribe(jest.fn()); -======= let everErrored = false; myState.subscribe((state) => { if (state.isError) { everErrored = true; } }); ->>>>>>> b2be866 (broke something) myParent.set('a'); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -423,7 +405,7 @@ describe('asyncWritable', () => { const myParent = writable(); const { store: myStore, state: myState } = asyncDerived(myParent, load); - myStore.subscribe(jest.fn()); + myStore.subscribe(vi.fn()); myParent.set('one'); let loadValue = await myStore.load(); expect(loadValue).toBe('one'); @@ -478,7 +460,7 @@ describe('asyncWritable', () => { undefined, () => new Promise((resolve) => setTimeout(resolve, 1000)) ); - const mockLoad = jest + const mockLoad = vi .fn() .mockReturnValue('default') .mockResolvedValueOnce('first value') @@ -491,13 +473,7 @@ describe('asyncWritable', () => { return `${$writableParent}: ${reloadValue}`; } ); -<<<<<<< HEAD myDerived.subscribe(vi.fn()); -||||||| parent of 7cc4482 (mostly working except for rejections) - myDerived.subscribe(jest.fn); -======= - myDerived.subscribe(jest.fn()); ->>>>>>> 7cc4482 (mostly working except for rejections) writableParent.set('A'); writableParent.set('B'); writableParent.set('C'); @@ -696,8 +672,6 @@ describe('asyncWritable', () => { expect(mappingWriteFunction).toHaveBeenCalledTimes(1); }); -<<<<<<< HEAD -<<<<<<< HEAD it('still sets value when rejected', async () => { const mappingWriteFunction = vi.fn(() => Promise.reject(new Error('any')) @@ -709,56 +683,6 @@ describe('asyncWritable', () => { { initial: 'initial' } ); myAsyncWritable.subscribe(vi.fn()); -||||||| parent of 7cc4482 (mostly working except for rejections) - 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); ->>>>>>> 7cc4482 (mostly working except for rejections) -||||||| parent of b2be866 (broke something) - // 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); ->>>>>>> b2be866 (broke something) expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); await myAsyncWritable.load().catch(() => Promise.resolve()); @@ -915,29 +839,9 @@ describe('asyncWritable', () => { logAsyncErrors(undefined); }); -<<<<<<< HEAD -<<<<<<< HEAD it('does not call error logger when no error', async () => { const errorLogger = vi.fn(); logAsyncErrors(errorLogger); -||||||| parent of 7cc4482 (mostly working except for rejections) - 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); ->>>>>>> 7cc4482 (mostly working except for rejections) -||||||| parent of b2be866 (broke something) - // 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); ->>>>>>> b2be866 (broke something) const myReadable = asyncReadable(undefined, () => Promise.resolve('value') @@ -947,35 +851,15 @@ describe('asyncWritable', () => { expect(errorLogger).not.toHaveBeenCalled(); }); -<<<<<<< HEAD -<<<<<<< HEAD it('does call error logger when async error', async () => { const errorLogger = vi.fn(); logAsyncErrors(errorLogger); -||||||| parent of 7cc4482 (mostly working except for rejections) - 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); ->>>>>>> 7cc4482 (mostly working except for rejections) -||||||| parent of b2be866 (broke something) - // 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); ->>>>>>> b2be866 (broke something) const myReadable = asyncReadable(undefined, () => Promise.reject(new Error('error')) ); - myReadable.subscribe(jest.fn()); + myReadable.subscribe(vi.fn()); // perform multiple loads and make sure logger only called once await safeLoad(myReadable); @@ -1169,7 +1053,7 @@ describe('trackState', () => { { trackState: true } ); - myStore.subscribe(jest.fn()); + myStore.subscribe(vi.fn()); expect(get(myState).isLoading).toBe(true); @@ -1200,7 +1084,7 @@ describe('trackState', () => { { trackState: true } ); - myStore.subscribe(jest.fn()); + myStore.subscribe(vi.fn()); expect(get(myState).isLoading).toBe(true); @@ -1274,7 +1158,7 @@ describe('trackState', () => { { trackState: true } ); - myStore.subscribe(jest.fn()); + myStore.subscribe(vi.fn()); expect(get(myState).isLoading).toBe(true); @@ -1321,7 +1205,7 @@ describe('trackState', () => { { trackState: true, reloadable: true, debug: 'thing' } ); - myStore.subscribe(jest.fn()); + myStore.subscribe(vitest.fn()); expect(get(myState).isLoading).toBe(true); @@ -1349,7 +1233,7 @@ describe('trackState', () => { { trackState: true } ); - myStore.subscribe(jest.fn()); + myStore.subscribe(vi.fn()); expect(get(myState).isLoading).toBe(true); @@ -1405,19 +1289,7 @@ describe('trackState', () => { expect(get(myState).isWriting).toBe(true); -<<<<<<< HEAD -<<<<<<< HEAD await setPromise.catch(vi.fn()); -||||||| parent of 7cc4482 (mostly working except for rejections) - await setPromise.catch(jest.fn()); -======= - // await setPromise.catch(jest.fn()); ->>>>>>> 7cc4482 (mostly working except for rejections) -||||||| parent of b2be866 (broke something) - // await setPromise.catch(jest.fn()); -======= - await setPromise.catch(jest.fn()); ->>>>>>> b2be866 (broke something) expect(get(myState).isError).toBe(true); }); diff --git a/test/testing.test.ts b/test/testing.test.ts index 2e4a135..4df15e3 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -24,12 +24,13 @@ const { store: myReadable, state: myState } = asyncReadable( ); beforeEach(() => { + console.log('resetting'); myReadable.reset(); }); describe('can be reset for different tests', () => { it('loads resolution', async () => { - myReadable.subscribe(vi.fn()); + const unsubscribe = myReadable.subscribe(vi.fn()); mockedFetch.mockResolvedValueOnce('loaded'); await myReadable.load(); @@ -39,15 +40,16 @@ describe('can be reset for different tests', () => { await myReadable.load(); expect(get(myReadable)).toBe('loaded'); + unsubscribe(); }); it('loads rejection', async () => { console.log('starting failed test'); - // myReadable.subscribe(jest.fn()); + myReadable.subscribe(vi.fn()); mockedFetch.mockRejectedValueOnce('rejected'); await myReadable.load().catch(() => Promise.resolve()); - expect(get(myReadable)).toBe('initial'); + // expect(get(myReadable)).toBe('initial'); // mockedFetch.mockResolvedValueOnce('loaded'); // await myReadable.load().catch(() => Promise.resolve()); diff --git a/test/utils/index.test.ts b/test/utils/index.test.ts index 4989a8d..2679284 100644 --- a/test/utils/index.test.ts +++ b/test/utils/index.test.ts @@ -16,6 +16,7 @@ describe('loadAll / reloadAll utils', () => { const myLoadable = asyncReadable(undefined, () => Promise.resolve('loaded')); const myReloadable = asyncReadable(undefined, mockReload, { reloadable: true, + debug: 'myReloadable:', }); const badLoadable = { load: () => Promise.reject(new Error('E')), @@ -61,26 +62,28 @@ describe('loadAll / reloadAll utils', () => { }); it('reloads and resolves to values of all stores', async () => { + console.log('starting test'); await loadAll([myLoadable, myReloadable]); + console.log('starting reload'); expect(reloadAll([myLoadable, myReloadable])).resolves.toStrictEqual([ 'loaded', 'second value', ]); }); - it('handles rejection', () => { - expect(reloadAll([myLoadable, badLoadable])).rejects.toStrictEqual( - new Error('F') - ); - }); - - it('does not reload already visited store', () => { - const visitedMap = new WeakMap(); - visitedMap.set(myReloadable, myReloadable.reload()); - expect(reloadAll(myReloadable, visitedMap)).resolves.toStrictEqual( - 'first value' - ); - }); + // it('handles rejection', () => { + // expect(reloadAll([myLoadable, badLoadable])).rejects.toStrictEqual( + // new Error('F') + // ); + // }); + + // it('does not reload already visited store', () => { + // const visitedMap = new WeakMap(); + // visitedMap.set(myReloadable, myReloadable.reload()); + // expect(reloadAll(myReloadable, visitedMap)).resolves.toStrictEqual( + // 'first value' + // ); + // }); }); describe('safeLoad function', () => { From 499900ffb212f551791a107cc1eb23b070883a5b Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 15 Aug 2023 17:16:48 -0400 Subject: [PATCH 06/13] all tests working --- src/async-stores/index.ts | 22 ++++++++++------------ test/async-stores/index.test.ts | 33 ++++++++++++++++++++++++++------- test/testing.test.ts | 18 +++++------------- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index 81ac9a9..01830bd 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -134,23 +134,25 @@ export const asyncWritable = ( resolveCurrentLoad(finalValue); } catch (error) { debuggy?.('caught error', error); - if (error.name !== 'AbortError') { + 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'); 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 + // 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)); - } 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)); } } }; @@ -308,14 +310,10 @@ export const asyncWritable = ( const reset = getStoreTestingMode() ? () => { - // cleanupSubscriptions?.(); thisStore.set(initial); setState('LOADING'); ready = false; changeReceived = false; - // currentLoadPromise = undefined; - // setCurrentLoadPromise(); - // rebouncedSelfLoad.abort(); } : undefined; diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index add7226..038e4b9 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -271,13 +271,7 @@ describe('asyncWritable', () => { }; const myParent = writable(); - const { store: myStore, state: myState } = asyncDerived( - myParent, - load, - { - trackState: true, - } - ); + const { store: myStore, state: myState } = asyncDerived(myParent, load); let setIncorrectly = false; myStore.subscribe((value) => { @@ -395,6 +389,31 @@ describe('asyncWritable', () => { expect(get(myState).isLoaded).toBe(true); }); + it('resolves initial load if reloaded during', async () => { + const getFinalValue = vi + .fn() + .mockReturnValueOnce('first') + .mockReturnValueOnce('second'); + + const load = () => { + const valueToReturn = getFinalValue(); + console.log('valueToReturn', valueToReturn); + return new Promise((resolve) => + setTimeout(() => resolve(valueToReturn), 100) + ); + }; + + const myLoadable = asyncReadable('initial', load, { + reloadable: true, + debug: 'my thing:', + }); + + expect(myLoadable.load()).resolves.toBe('second'); + await new Promise((resolve) => setTimeout(resolve, 50)); + const finalValue = await myLoadable.reload(); + expect(finalValue).toBe('second'); + }); + it('can be aborted correctly', async () => { const load = (value: string) => { return new Promise((resolve) => diff --git a/test/testing.test.ts b/test/testing.test.ts index 4df15e3..1435818 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -15,16 +15,9 @@ import { enableStoreTestingMode(); const mockedFetch = vi.fn(); -const { store: myReadable, state: myState } = asyncReadable( - 'initial', - () => mockedFetch(), - { - debug: 'myReadable:', - } -); +const myReadable = asyncReadable('initial', () => mockedFetch()); beforeEach(() => { - console.log('resetting'); myReadable.reset(); }); @@ -44,17 +37,16 @@ describe('can be reset for different tests', () => { }); it('loads rejection', async () => { - console.log('starting failed test'); myReadable.subscribe(vi.fn()); mockedFetch.mockRejectedValueOnce('rejected'); await myReadable.load().catch(() => Promise.resolve()); - // expect(get(myReadable)).toBe('initial'); + 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'); }); }); From 87179fe71d348192125ed90fd2162d71afb42751 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 15 Aug 2023 18:12:55 -0400 Subject: [PATCH 07/13] everything working?? --- src/async-stores/index.ts | 36 ++++++++++++------------- test/async-stores/index.test.ts | 48 ++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index 01830bd..b5f596f 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -112,12 +112,6 @@ export const asyncWritable = ( let mostRecentLoadTracker: Record; const selfLoadThenSet = async () => { - if (get(loadState).isSettled) { - setCurrentLoadPromise(); - debuggy?.('setting RELOADING'); - setState('RELOADING'); - } - const thisLoadTracker = {}; mostRecentLoadTracker = thisLoadTracker; @@ -182,20 +176,24 @@ export const asyncWritable = ( }; initialLoad(); - const parentUnsubscribers = getStoresArray(stores).map((store, i) => - store.subscribe((value) => { - debuggy?.('received value', value); - changeReceived = true; - if (Array.isArray(stores)) { - parentValues[i] = value; - } else { - parentValues = value as StoresValues; - } - if (ready) { - debuggy?.('proceeding because ready'); - selfLoadThenSet(); + const onSubscriptionUpdate = async () => { + changeReceived = true; + if (ready) { + if (get(loadState).isSettled) { + setCurrentLoadPromise(); + debuggy?.('setting RELOADING'); + setState('RELOADING'); } - }) + ready = false; + parentValues = await loadAll(stores); + // eslint-disable-next-line require-atomic-updates + ready = true; + selfLoadThenSet(); + } + }; + + const parentUnsubscribers = getStoresArray(stores).map((store) => + store.subscribe(onSubscriptionUpdate) ); cleanupSubscriptions = () => { diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index 038e4b9..f9ef60d 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -224,7 +224,7 @@ describe('asyncWritable', () => { expect(myAsyncDerived.load()).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`) @@ -254,7 +254,7 @@ describe('asyncWritable', () => { firstUnsubscribe(); writableParent.set('updated'); - await new Promise((resolve) => setTimeout(resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('updated second'); @@ -290,6 +290,7 @@ describe('asyncWritable', () => { 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); @@ -509,7 +510,48 @@ describe('asyncWritable', () => { expect(get(myDerived)).toBe('L: first value'); }); - // it('calls selfLoad once when multiple ') + 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, + (value) => + new Promise((resolve) => + setTimeout(() => resolve(value.toUpperCase()), 100) + ) + ); + const load = vi.fn( + ([valueA, valueB]) => + new Promise((resolve) => + setTimeout(() => resolve(valueA + valueB), 100) + ) + ); + const myLoadable = asyncDerived([parentA, parentB], load, { + debug: 'myLoadable', + }); + 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', () => { From 1009d3d67047c0a277f7efe9e9e5b7fbb8014fb4 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 15 Aug 2023 18:17:44 -0400 Subject: [PATCH 08/13] cleanup debugging log calls --- src/async-stores/index-copy.ts | 310 -------------------------------- src/async-stores/index.ts | 28 +-- src/async-stores/types.ts | 1 - test/async-stores/index.test.ts | 12 +- 4 files changed, 6 insertions(+), 345 deletions(-) delete mode 100644 src/async-stores/index-copy.ts diff --git a/src/async-stores/index-copy.ts b/src/async-stores/index-copy.ts deleted file mode 100644 index e1f124a..0000000 --- a/src/async-stores/index-copy.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { get, type Updater, type Readable, writable } from 'svelte/store'; -import type { - AsyncStoreOptions, - Loadable, - LoadState, - State, - Stores, - StoresValues, - WritableLoadable, - VisitedMap, -} from './types.js'; -import { - anyReloadable, - getStoresArray, - reloadAll, - loadAll, -} from '../utils/index.js'; -import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; - -// STORES - -const getLoadState = (stateString: State): LoadState => { - return { - isLoading: stateString === 'LOADING', - isReloading: stateString === 'RELOADING', - isLoaded: stateString === 'LOADED', - isWriting: stateString === 'WRITING', - isError: stateString === 'ERROR', - isPending: stateString === 'LOADING' || stateString === 'RELOADING', - isSettled: stateString === 'LOADED' || stateString === 'ERROR', - }; -}; - -/** - * Generate a Loadable store that is considered 'loaded' after resolving synchronous or asynchronous behavior. - * This behavior may be derived from the value of parent Loadable or non Loadable stores. - * If so, this store will begin loading only after the parents have loaded. - * This store is also writable. It includes a `set` function that will immediately update the value of the store - * 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 - * 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. - * 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. - * The loaded value of the store will be ready after awaiting the load function of this store. - */ -export const asyncWritable = ( - stores: S, - mappingLoadFunction: (values: StoresValues) => Promise | T, - mappingWriteFunction?: ( - value: T, - parentValues?: StoresValues, - oldValue?: T - ) => Promise, - options: AsyncStoreOptions = {} -): WritableLoadable => { - flagStoreCreated(); - const { reloadable, trackState, initial } = options; - - const loadState = trackState - ? writable(getLoadState('LOADING')) - : undefined; - - const setState = (state: State) => loadState?.set(getLoadState(state)); - - // stringified representation of parents' loaded values - // used to track whether a change has occurred and the store reloaded - let loadedValuesString: string; - - let latestLoadAndSet: () => Promise; - - // most recent call of mappingLoadFunction, including resulting side effects - // (updating store value, tracking state, etc) - let currentLoadPromise: Promise; - - const tryLoad = async (values: StoresValues) => { - try { - return await mappingLoadFunction(values); - } catch (e) { - if (e.name !== 'AbortError') { - logError(e); - setState('ERROR'); - } - throw e; - } - }; - - // 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()); - }; - }); - - loadDependenciesThenSet = async ( - parentLoadFunction: (stores: S) => Promise>, - forceReload = false - ) => { - const loadParentStores = parentLoadFunction(stores); - - try { - await loadParentStores; - } catch { - currentLoadPromise = loadParentStores as Promise; - setState('ERROR'); - return currentLoadPromise; - } - - const storeValues = getStoresArray(stores).map((store) => - get(store) - ) as StoresValues; - - if (!forceReload) { - const newValuesString = JSON.stringify(storeValues); - if (newValuesString === loadedValuesString) { - // no change, don't generate new promise - return currentLoadPromise; - } - loadedValuesString = newValuesString; - } - - // convert storeValues to single store value if expected by mapping function - const loadInput = Array.isArray(stores) ? storeValues : storeValues[0]; - - const loadAndSet = async () => { - latestLoadAndSet = loadAndSet; - if (get(loadState)?.isSettled) { - setState('RELOADING'); - } - 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); - } - throw e; - } - }; - - currentLoadPromise = loadAndSet(); - return currentLoadPromise; - }; - - const setStoreValueThenWrite = async ( - updater: Updater, - persist?: boolean - ) => { - setState('WRITING'); - let oldValue: T; - try { - oldValue = await loadDependenciesThenSet(loadAll); - } catch { - oldValue = get(thisStore); - } - const newValue = updater(oldValue); - currentLoadPromise = currentLoadPromise - .then(() => newValue) - .catch(() => newValue); - thisStore.set(newValue); - - if (mappingWriteFunction && persist) { - try { - const parentValues = await loadAll(stores); - - const writeResponse = (await mappingWriteFunction( - newValue, - parentValues, - oldValue - )) as T; - - if (writeResponse !== undefined) { - thisStore.set(writeResponse); - currentLoadPromise = currentLoadPromise.then(() => writeResponse); - } - } catch (e) { - logError(e); - setState('ERROR'); - throw e; - } - } - setState('LOADED'); - }; - - // required properties - const subscribe = thisStore.subscribe; - 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 reset = getStoreTestingMode() - ? () => { - thisStore.set(initial); - setState('LOADING'); - loadedValuesString = undefined; - currentLoadPromise = undefined; - } - : undefined; - - return { - get store() { - return this; - }, - subscribe, - set, - update, - load, - ...(reload && { reload }), - ...(state && { state }), - ...(reset && { reset }), - }; -}; - -/** - * Generate a Loadable store that is considered 'loaded' after resolving asynchronous behavior. - * This asynchronous behavior may be derived from the value of parent Loadable or non Loadable stores. - * 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 - * 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. - * The loaded value of the store will be ready after awaiting the load function of this store. - */ -export const asyncDerived = ( - stores: S, - mappingLoadFunction: (values: StoresValues) => Promise, - options?: AsyncStoreOptions -): Loadable => { - const { store, subscribe, load, reload, state, reset } = asyncWritable( - stores, - mappingLoadFunction, - undefined, - options - ); - - return { - store, - subscribe, - load, - ...(reload && { reload }), - ...(state && { state }), - ...(reset && { reset }), - }; -}; - -/** - * 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 - * 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. - * The loaded value of the store will be ready after awaiting the load function of this store. - */ -export const asyncReadable = ( - initial: T, - loadFunction: () => Promise, - options?: Omit, 'initial'> -): Loadable => { - return asyncDerived([], loadFunction, { ...options, initial }); -}; diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index b5f596f..6a10a0b 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -72,9 +72,7 @@ export const asyncWritable = ( let thisStore: Writable; flagStoreCreated(); - const { reloadable, initial, debug, rebounceDelay } = options; - - const debuggy = debug ? (...args) => console.log(debug, ...args) : undefined; + const { reloadable, initial, rebounceDelay } = options; const rebouncedSelfLoad = rebounce(selfLoadFunction, rebounceDelay); @@ -91,16 +89,13 @@ export const asyncWritable = ( let resolveCurrentLoad: (value: T | PromiseLike | Error) => void; const setCurrentLoadPromise = () => { - debuggy?.('setCurrentLoadPromise -> new load promise generated'); currentLoadPromise = new Promise((resolve) => { resolveCurrentLoad = resolve; }); }; 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; @@ -118,16 +113,13 @@ export const asyncWritable = ( try { // parentValues const finalValue = (await rebouncedSelfLoad(parentValues)) as T; - debuggy?.('setting value'); thisStore.set(finalValue); if (!get(loadState).isWriting) { - debuggy?.('setting LOADED'); setState('LOADED'); } resolveCurrentLoad(finalValue); } catch (error) { - debuggy?.('caught error', error); if (error.name === 'AbortError') { if (thisLoadTracker === mostRecentLoadTracker) { // Normally when a load is aborted we want to leave the state as is. @@ -139,7 +131,7 @@ export const asyncWritable = ( } else { logError(error); setState('ERROR'); - debuggy?.('resolving current load with error', error); + // 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, @@ -155,16 +147,13 @@ export const asyncWritable = ( // called when store receives its first subscriber const onFirstSubscription: StartStopNotifier = () => { - debuggy?.('onFirstSubscription'); setCurrentLoadPromise(); parentValues = getAll(stores); setState('LOADING'); const initialLoad = async () => { - debuggy?.('initial load called'); try { parentValues = await loadAll(stores); - debuggy?.('setting ready'); ready = true; changeReceived = false; selfLoadThenSet(); @@ -181,7 +170,6 @@ export const asyncWritable = ( if (ready) { if (get(loadState).isSettled) { setCurrentLoadPromise(); - debuggy?.('setting RELOADING'); setState('RELOADING'); } ready = false; @@ -197,17 +185,13 @@ export const asyncWritable = ( ); cleanupSubscriptions = () => { - debuggy?.('cleaning up subscriptions'); parentUnsubscribers.map((unsubscriber) => unsubscriber()); ready = false; changeReceived = false; }; // called on losing last subscriber - return () => { - debuggy?.('stopping store'); - cleanupSubscriptions(); - }; + return cleanupSubscriptions; }; thisStore = writable(initial, onFirstSubscription); @@ -242,7 +226,6 @@ export const asyncWritable = ( } } catch (error) { logError(error); - debuggy?.('setting ERROR'); setState('ERROR'); resolveCurrentLoad(newValue); throw error; @@ -270,19 +253,15 @@ export const asyncWritable = ( ready = false; changeReceived = false; 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 { parentValues = await reloadAll(stores, visitMap); - debuggy?.('parentValues', parentValues); ready = true; - debuggy?.(changeReceived, reloadable, wasErrored); if (changeReceived || reloadable || wasErrored) { selfLoadThenSet(); } else { @@ -290,7 +269,6 @@ export const asyncWritable = ( setState('LOADED'); } } catch (error) { - debuggy?.('caught error during reload'); setState('ERROR'); resolveCurrentLoad(error); } diff --git a/src/async-stores/types.ts b/src/async-stores/types.ts index 957d505..53a86ee 100644 --- a/src/async-stores/types.ts +++ b/src/async-stores/types.ts @@ -42,7 +42,6 @@ export type WritableLoadable = AsyncLoadable & AsyncWritable; export interface AsyncStoreOptions { reloadable?: true; trackState?: true; - debug?: string; initial?: T; rebounceDelay?: number; } diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index f9ef60d..04b1cfe 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -9,8 +9,6 @@ import { derived, readable, writable, - isReloadable, - rebounce, safeLoad, } from '../../src'; @@ -398,7 +396,7 @@ describe('asyncWritable', () => { const load = () => { const valueToReturn = getFinalValue(); - console.log('valueToReturn', valueToReturn); + return new Promise((resolve) => setTimeout(() => resolve(valueToReturn), 100) ); @@ -406,7 +404,6 @@ describe('asyncWritable', () => { const myLoadable = asyncReadable('initial', load, { reloadable: true, - debug: 'my thing:', }); expect(myLoadable.load()).resolves.toBe('second'); @@ -534,9 +531,7 @@ describe('asyncWritable', () => { setTimeout(() => resolve(valueA + valueB), 100) ) ); - const myLoadable = asyncDerived([parentA, parentB], load, { - debug: 'myLoadable', - }); + const myLoadable = asyncDerived([parentA, parentB], load); myLoadable.subscribe(vi.fn()); let result = await myLoadable.load(); @@ -1263,7 +1258,7 @@ describe('trackState', () => { const { store: myStore, state: myState } = asyncReadable( 'initial', load, - { trackState: true, reloadable: true, debug: 'thing' } + { trackState: true, reloadable: true } ); myStore.subscribe(vitest.fn()); @@ -1286,7 +1281,6 @@ describe('trackState', () => { .mockRejectedValueOnce('failure'); const myParent = asyncReadable('initial', parentLoad, { reloadable: true, - debug: 'parent:', }); const { store: myStore, state: myState } = asyncDerived( myParent, From 852d7fbbbebbea77fb1ca8b735c8e524d20f328b Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Tue, 15 Aug 2023 18:27:18 -0400 Subject: [PATCH 09/13] dont track parentValues --- src/async-stores/index.ts | 19 ++++++++----------- test/utils/index.test.ts | 30 +++++++++++++----------------- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index 6a10a0b..c28d6c5 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -103,10 +103,8 @@ export const asyncWritable = ( return currentLoadPromise as T; }; - let parentValues: StoresValues; - let mostRecentLoadTracker: Record; - const selfLoadThenSet = async () => { + const selfLoadThenSet = async (parentValues: StoresValues) => { const thisLoadTracker = {}; mostRecentLoadTracker = thisLoadTracker; @@ -148,15 +146,14 @@ export const asyncWritable = ( // called when store receives its first subscriber const onFirstSubscription: StartStopNotifier = () => { setCurrentLoadPromise(); - parentValues = getAll(stores); setState('LOADING'); const initialLoad = async () => { try { - parentValues = await loadAll(stores); + const parentValues = await loadAll(stores); ready = true; changeReceived = false; - selfLoadThenSet(); + selfLoadThenSet(parentValues); } catch (error) { ready = true; changeReceived = false; @@ -173,10 +170,10 @@ export const asyncWritable = ( setState('RELOADING'); } ready = false; - parentValues = await loadAll(stores); + const parentValues = await loadAll(stores); // eslint-disable-next-line require-atomic-updates ready = true; - selfLoadThenSet(); + selfLoadThenSet(parentValues); } }; @@ -216,7 +213,7 @@ export const asyncWritable = ( try { const writeResponse = (await writePersistFunction( newValue, - parentValues, + getAll(stores), oldValue )) as T; @@ -260,10 +257,10 @@ export const asyncWritable = ( const visitMap = visitedMap ?? new WeakMap(); try { - parentValues = await reloadAll(stores, visitMap); + const parentValues = await reloadAll(stores, visitMap); ready = true; if (changeReceived || reloadable || wasErrored) { - selfLoadThenSet(); + selfLoadThenSet(parentValues); } else { resolveCurrentLoad(get(thisStore)); setState('LOADED'); diff --git a/test/utils/index.test.ts b/test/utils/index.test.ts index 2679284..91cac4f 100644 --- a/test/utils/index.test.ts +++ b/test/utils/index.test.ts @@ -1,7 +1,6 @@ import { asyncReadable, Loadable, - readable, loadAll, rebounce, reloadAll, @@ -16,7 +15,6 @@ describe('loadAll / reloadAll utils', () => { const myLoadable = asyncReadable(undefined, () => Promise.resolve('loaded')); const myReloadable = asyncReadable(undefined, mockReload, { reloadable: true, - debug: 'myReloadable:', }); const badLoadable = { load: () => Promise.reject(new Error('E')), @@ -62,28 +60,26 @@ describe('loadAll / reloadAll utils', () => { }); it('reloads and resolves to values of all stores', async () => { - console.log('starting test'); await loadAll([myLoadable, myReloadable]); - console.log('starting reload'); expect(reloadAll([myLoadable, myReloadable])).resolves.toStrictEqual([ 'loaded', 'second value', ]); }); - // it('handles rejection', () => { - // expect(reloadAll([myLoadable, badLoadable])).rejects.toStrictEqual( - // new Error('F') - // ); - // }); - - // it('does not reload already visited store', () => { - // const visitedMap = new WeakMap(); - // visitedMap.set(myReloadable, myReloadable.reload()); - // expect(reloadAll(myReloadable, visitedMap)).resolves.toStrictEqual( - // 'first value' - // ); - // }); + it('handles rejection', () => { + expect(reloadAll([myLoadable, badLoadable])).rejects.toStrictEqual( + new Error('F') + ); + }); + + it('does not reload already visited store', () => { + const visitedMap = new WeakMap(); + visitedMap.set(myReloadable, myReloadable.reload()); + expect(reloadAll(myReloadable, visitedMap)).resolves.toStrictEqual( + 'first value' + ); + }); }); describe('safeLoad function', () => { From daaa643ae3834ce7d417696927fec8351709bfe6 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Wed, 16 Aug 2023 12:05:31 -0400 Subject: [PATCH 10/13] fix store loading prematurely when multiple updates received --- src/async-stores/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index c28d6c5..d40df12 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -236,10 +236,11 @@ export const asyncWritable = ( // required properties const subscribe = thisStore.subscribe; - const load = () => { + const load = async () => { const dummyUnsubscribe = thisStore.subscribe(() => { /* no-op */ }); + await loadAll(stores); return getLoadedValueOrThrow(dummyUnsubscribe); }; From 28a6ea11cffeb66cf2c202effb2aace2d2ef0c51 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Wed, 16 Aug 2023 12:15:45 -0400 Subject: [PATCH 11/13] chore: test cleanup --- CHANGELOG.md | 1 + test/async-stores/index.test.ts | 146 ++++++++++------------------- test/helpers.ts | 17 ++++ test/standard-stores/index.test.ts | 1 - test/testing.test.ts | 3 +- test/utils/index.test.ts | 10 +- 6 files changed, 73 insertions(+), 105 deletions(-) create mode 100644 test/helpers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 55e3b8c..46c747a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ notes: - 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) diff --git a/test/async-stores/index.test.ts b/test/async-stores/index.test.ts index 04b1cfe..fe775dc 100644 --- a/test/async-stores/index.test.ts +++ b/test/async-stores/index.test.ts @@ -11,6 +11,7 @@ import { writable, safeLoad, } from '../../src'; +import { delayValue, delayFunction } from '../helpers'; describe('asyncWritable', () => { const writableParent = writable('writable'); @@ -83,7 +84,7 @@ describe('asyncWritable', () => { }); describe('one parent asyncDerived', () => { - it('loads expected value NOMERGE', async () => { + it('loads expected value', async () => { const myAsyncDerived = asyncDerived(writableParent, (storeValue) => Promise.resolve(`derived from ${storeValue}`) ); @@ -189,7 +190,7 @@ describe('asyncWritable', () => { expect(mockReload).toHaveBeenCalledTimes(2); }); - it('rejects load when parent load fails', () => { + it('rejects load and reload when parent load fails', () => { const asyncReadableParent = asyncReadable( undefined, () => Promise.reject(new Error('error')), @@ -207,21 +208,6 @@ describe('asyncWritable', () => { expect(myAsyncDerived.reload()).rejects.toStrictEqual(new Error('error')); }); - it('rejects reload when parent load fails', () => { - const asyncReadableParent = asyncReadable(undefined, () => - Promise.reject(new Error('error')) - ); - 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')); - }); - it('correctly unsubscribes from parents', async () => { const writableParent = writable('initial'); const firstDerivedLoad = vi.fn(($parent) => @@ -242,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, 100)); + await delayValue(null, 100); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('initial second'); @@ -252,7 +238,7 @@ describe('asyncWritable', () => { firstUnsubscribe(); writableParent.set('updated'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); expect(firstValue).toBe('initial first'); expect(secondValue).toBe('updated second'); @@ -262,11 +248,7 @@ describe('asyncWritable', () => { describe('abort/rebounce integration', () => { it('loads to rebounced value only', async () => { - const load = (value: string) => { - return new Promise((resolve) => - setTimeout(() => resolve(value), 100) - ); - }; + const load = (value: string) => delayValue(value, 100); const myParent = writable(); const { store: myStore, state: myState } = asyncDerived(myParent, load); @@ -286,14 +268,15 @@ 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(); @@ -334,13 +317,15 @@ 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(); @@ -355,9 +340,7 @@ describe('asyncWritable', () => { let timesCalled = 0; const load = (value: string) => { timesCalled += 1; - return new Promise((resolve) => - setTimeout(() => resolve(value), 200 - timesCalled * 100) - ); + return delayValue(value, 200 - timesCalled * 100); }; const myParent = writable(); @@ -382,7 +365,7 @@ describe('asyncWritable', () => { const result = await myStore.load(); expect(result).toBe('b'); - await new Promise((resolve) => setTimeout(resolve, 200)); + await delayValue(null, 200); expect(get(myStore)).toBe('b'); expect(setIncorrectly).toBe(false); expect(get(myState).isLoaded).toBe(true); @@ -394,33 +377,26 @@ describe('asyncWritable', () => { .mockReturnValueOnce('first') .mockReturnValueOnce('second'); - const load = () => { - const valueToReturn = getFinalValue(); - - return new Promise((resolve) => - setTimeout(() => resolve(valueToReturn), 100) - ); - }; - - const myLoadable = asyncReadable('initial', load, { - reloadable: true, - }); + const myLoadable = asyncReadable( + 'initial', + delayFunction(getFinalValue, 100), + { + reloadable: true, + } + ); expect(myLoadable.load()).resolves.toBe('second'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); const finalValue = await myLoadable.reload(); expect(finalValue).toBe('second'); }); 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); + const { store: myStore, state: myState } = asyncDerived( + myParent, + delayFunction((value) => value, 100) + ); myStore.subscribe(vi.fn()); myParent.set('one'); @@ -428,7 +404,7 @@ describe('asyncWritable', () => { expect(loadValue).toBe('one'); myParent.set('two'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await delayValue(null, 50); myStore.abort(); loadValue = await myStore.load(); @@ -475,7 +451,7 @@ 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() @@ -520,18 +496,17 @@ describe('asyncWritable', () => { const parentA = derived(grandParent, (value) => value); const parentB = asyncDerived( grandParent, - (value) => - new Promise((resolve) => - setTimeout(() => resolve(value.toUpperCase()), 100) - ) + delayFunction((value) => value.toUpperCase(), 100) + ); + const parentC = asyncDerived( + parentB, + delayFunction((value) => value, 100) ); + const load = vi.fn( - ([valueA, valueB]) => - new Promise((resolve) => - setTimeout(() => resolve(valueA + valueB), 100) - ) + delayFunction(([valueA, valueB]) => valueA + valueB, 100) ); - const myLoadable = asyncDerived([parentA, parentB], load); + const myLoadable = asyncDerived([parentA, parentC], load); myLoadable.subscribe(vi.fn()); let result = await myLoadable.load(); @@ -756,10 +731,8 @@ describe('asyncWritable', () => { ); myAsyncWritable.subscribe(vi.fn()); - let value = await myAsyncWritable.load(); - expect(value).toBe('first value'); - value = await myAsyncWritable.reload(); - expect(value).toBe('first value'); + expect(await myAsyncWritable.load()).toBe('first value'); + expect(await myAsyncWritable.reload()).toBe('first value'); }); it('does reload if reloadable', async () => { @@ -800,10 +773,8 @@ describe('asyncWritable', () => { ); myAsyncWritable.subscribe(vi.fn()); - let value = await myAsyncWritable.load(); - expect(value).toBe('derived from first value'); - value = await myAsyncWritable.reload(); - expect(value).toBe('derived from first value'); + 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 () => { @@ -868,7 +839,7 @@ 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'); @@ -1021,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( [], @@ -1033,9 +1004,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - const result = await myStore.load(); - - expect(result).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -1049,9 +1018,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - const result = await myStore.load(); - - expect(result).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); @@ -1065,9 +1032,7 @@ describe('trackState', () => { expect(get(myStore)).toBe('initial'); expect(get(myState).isLoading).toBe(true); - const result = await myStore.load(); - - expect(result).toBe('loaded value'); + expect(await myStore.load()).toBe('loaded value'); expect(get(myState).isLoaded).toBe(true); }); }); @@ -1164,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 } ); @@ -1181,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); @@ -1204,13 +1165,7 @@ 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 } ); @@ -1224,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); 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..6da72b2 100644 --- a/test/standard-stores/index.test.ts +++ b/test/standard-stores/index.test.ts @@ -227,7 +227,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 1435818..75cbc9d 100644 --- a/test/testing.test.ts +++ b/test/testing.test.ts @@ -37,7 +37,7 @@ describe('can be reset for different tests', () => { }); it('loads rejection', async () => { - myReadable.subscribe(vi.fn()); + const unsubscribe = myReadable.subscribe(vi.fn()); mockedFetch.mockRejectedValueOnce('rejected'); await myReadable.load().catch(() => Promise.resolve()); @@ -47,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 91cac4f..b5b8e89 100644 --- a/test/utils/index.test.ts +++ b/test/utils/index.test.ts @@ -7,6 +7,7 @@ import { safeLoad, enableStoreTestingMode, } from '../../src'; +import { delayValue } from '../helpers'; enableStoreTestingMode(); @@ -111,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); From 3eee26d0e2571fba1f0fa6cc07b03e45c4279e00 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Wed, 16 Aug 2023 14:27:26 -0400 Subject: [PATCH 12/13] support standard store update in start function --- src/async-stores/index.ts | 4 ---- src/standard-stores/index.ts | 20 +++++++++++++++++--- test/standard-stores/index.test.ts | 22 +++++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index d40df12..c46628a 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -1,15 +1,12 @@ import { get, type Updater, - type Readable, writable, StartStopNotifier, - readable, Writable, } from 'svelte/store'; import type { AsyncStoreOptions, - Loadable, LoadState, State, Stores, @@ -19,7 +16,6 @@ import type { AsyncLoadable, } from './types.js'; import { - anyReloadable, getStoresArray, reloadAll, loadAll, diff --git a/src/standard-stores/index.ts b/src/standard-stores/index.ts index 7cd1521..4248b99 100644 --- a/src/standard-stores/index.ts +++ b/src/standard-stores/index.ts @@ -9,7 +9,7 @@ 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, @@ -132,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/test/standard-stores/index.test.ts b/test/standard-stores/index.test.ts index 6da72b2..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 () => { From 19b9f2ea7d1eff3c854f2344355cad4c4dbf6921 Mon Sep 17 00:00:00 2001 From: Scott Beale Date: Thu, 17 Aug 2023 15:07:45 -0400 Subject: [PATCH 13/13] more stuff --- src/async-stores/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/async-stores/index.ts b/src/async-stores/index.ts index c46628a..89574e5 100644 --- a/src/async-stores/index.ts +++ b/src/async-stores/index.ts @@ -177,14 +177,12 @@ export const asyncWritable = ( store.subscribe(onSubscriptionUpdate) ); - cleanupSubscriptions = () => { + // called on losing last subscriber + return () => { parentUnsubscribers.map((unsubscriber) => unsubscriber()); ready = false; changeReceived = false; }; - - // called on losing last subscriber - return cleanupSubscriptions; }; thisStore = writable(initial, onFirstSubscription);