|
| 1 | +import { get, type Updater, type Readable, writable } from 'svelte/store'; |
| 2 | +import type { |
| 3 | + AsyncStoreOptions, |
| 4 | + Loadable, |
| 5 | + LoadState, |
| 6 | + State, |
| 7 | + Stores, |
| 8 | + StoresValues, |
| 9 | + WritableLoadable, |
| 10 | + VisitedMap, |
| 11 | +} from './types.js'; |
| 12 | +import { |
| 13 | + anyReloadable, |
| 14 | + getStoresArray, |
| 15 | + reloadAll, |
| 16 | + loadAll, |
| 17 | +} from '../utils/index.js'; |
| 18 | +import { flagStoreCreated, getStoreTestingMode, logError } from '../config.js'; |
| 19 | + |
| 20 | +// STORES |
| 21 | + |
| 22 | +const getLoadState = (stateString: State): LoadState => { |
| 23 | + return { |
| 24 | + isLoading: stateString === 'LOADING', |
| 25 | + isReloading: stateString === 'RELOADING', |
| 26 | + isLoaded: stateString === 'LOADED', |
| 27 | + isWriting: stateString === 'WRITING', |
| 28 | + isError: stateString === 'ERROR', |
| 29 | + isPending: stateString === 'LOADING' || stateString === 'RELOADING', |
| 30 | + isSettled: stateString === 'LOADED' || stateString === 'ERROR', |
| 31 | + }; |
| 32 | +}; |
| 33 | + |
| 34 | +/** |
| 35 | + * Generate a Loadable store that is considered 'loaded' after resolving synchronous or asynchronous behavior. |
| 36 | + * This behavior may be derived from the value of parent Loadable or non Loadable stores. |
| 37 | + * If so, this store will begin loading only after the parents have loaded. |
| 38 | + * This store is also writable. It includes a `set` function that will immediately update the value of the store |
| 39 | + * and then execute provided asynchronous behavior to persist this change. |
| 40 | + * @param stores Any readable or array of Readables whose value is used to generate the asynchronous behavior of this store. |
| 41 | + * Any changes to the value of these stores post-load will restart the asynchronous behavior of the store using the new values. |
| 42 | + * @param mappingLoadFunction A function that takes in the values of the stores and generates a Promise that resolves |
| 43 | + * to the final value of the store when the asynchronous behavior is complete. |
| 44 | + * @param mappingWriteFunction A function that takes in the new value of the store and uses it to perform async behavior. |
| 45 | + * Typically this would be to persist the change. If this value resolves to a value the store will be set to it. |
| 46 | + * @param options Modifiers for store behavior. |
| 47 | + * @returns A Loadable store whose value is set to the resolution of provided async behavior. |
| 48 | + * The loaded value of the store will be ready after awaiting the load function of this store. |
| 49 | + */ |
| 50 | +export const asyncWritable = <S extends Stores, T>( |
| 51 | + stores: S, |
| 52 | + mappingLoadFunction: (values: StoresValues<S>) => Promise<T> | T, |
| 53 | + mappingWriteFunction?: ( |
| 54 | + value: T, |
| 55 | + parentValues?: StoresValues<S>, |
| 56 | + oldValue?: T |
| 57 | + ) => Promise<void | T>, |
| 58 | + options: AsyncStoreOptions<T> = {} |
| 59 | +): WritableLoadable<T> => { |
| 60 | + flagStoreCreated(); |
| 61 | + const { reloadable, trackState, initial } = options; |
| 62 | + |
| 63 | + const loadState = trackState |
| 64 | + ? writable<LoadState>(getLoadState('LOADING')) |
| 65 | + : undefined; |
| 66 | + |
| 67 | + const setState = (state: State) => loadState?.set(getLoadState(state)); |
| 68 | + |
| 69 | + // stringified representation of parents' loaded values |
| 70 | + // used to track whether a change has occurred and the store reloaded |
| 71 | + let loadedValuesString: string; |
| 72 | + |
| 73 | + let latestLoadAndSet: () => Promise<T>; |
| 74 | + |
| 75 | + // most recent call of mappingLoadFunction, including resulting side effects |
| 76 | + // (updating store value, tracking state, etc) |
| 77 | + let currentLoadPromise: Promise<T>; |
| 78 | + |
| 79 | + const tryLoad = async (values: StoresValues<S>) => { |
| 80 | + try { |
| 81 | + return await mappingLoadFunction(values); |
| 82 | + } catch (e) { |
| 83 | + if (e.name !== 'AbortError') { |
| 84 | + logError(e); |
| 85 | + setState('ERROR'); |
| 86 | + } |
| 87 | + throw e; |
| 88 | + } |
| 89 | + }; |
| 90 | + |
| 91 | + // eslint-disable-next-line prefer-const |
| 92 | + let loadDependenciesThenSet: ( |
| 93 | + parentLoadFunction: (stores: S) => Promise<StoresValues<S>>, |
| 94 | + forceReload?: boolean |
| 95 | + ) => Promise<T>; |
| 96 | + |
| 97 | + const thisStore = writable(initial, () => { |
| 98 | + loadDependenciesThenSet(loadAll).catch(() => Promise.resolve()); |
| 99 | + |
| 100 | + const parentUnsubscribers = getStoresArray(stores).map((store) => |
| 101 | + store.subscribe(() => { |
| 102 | + loadDependenciesThenSet(loadAll).catch(() => Promise.resolve()); |
| 103 | + }) |
| 104 | + ); |
| 105 | + |
| 106 | + return () => { |
| 107 | + parentUnsubscribers.map((unsubscriber) => unsubscriber()); |
| 108 | + }; |
| 109 | + }); |
| 110 | + |
| 111 | + loadDependenciesThenSet = async ( |
| 112 | + parentLoadFunction: (stores: S) => Promise<StoresValues<S>>, |
| 113 | + forceReload = false |
| 114 | + ) => { |
| 115 | + const loadParentStores = parentLoadFunction(stores); |
| 116 | + |
| 117 | + try { |
| 118 | + await loadParentStores; |
| 119 | + } catch { |
| 120 | + currentLoadPromise = loadParentStores as Promise<T>; |
| 121 | + setState('ERROR'); |
| 122 | + return currentLoadPromise; |
| 123 | + } |
| 124 | + |
| 125 | + const storeValues = getStoresArray(stores).map((store) => |
| 126 | + get(store) |
| 127 | + ) as StoresValues<S>; |
| 128 | + |
| 129 | + if (!forceReload) { |
| 130 | + const newValuesString = JSON.stringify(storeValues); |
| 131 | + if (newValuesString === loadedValuesString) { |
| 132 | + // no change, don't generate new promise |
| 133 | + return currentLoadPromise; |
| 134 | + } |
| 135 | + loadedValuesString = newValuesString; |
| 136 | + } |
| 137 | + |
| 138 | + // convert storeValues to single store value if expected by mapping function |
| 139 | + const loadInput = Array.isArray(stores) ? storeValues : storeValues[0]; |
| 140 | + |
| 141 | + const loadAndSet = async () => { |
| 142 | + latestLoadAndSet = loadAndSet; |
| 143 | + if (get(loadState)?.isSettled) { |
| 144 | + setState('RELOADING'); |
| 145 | + } |
| 146 | + try { |
| 147 | + const finalValue = await tryLoad(loadInput); |
| 148 | + thisStore.set(finalValue); |
| 149 | + setState('LOADED'); |
| 150 | + return finalValue; |
| 151 | + } catch (e) { |
| 152 | + // if a load is aborted, resolve to the current value of the store |
| 153 | + if (e.name === 'AbortError') { |
| 154 | + // Normally when a load is aborted we want to leave the state as is. |
| 155 | + // However if the latest load is aborted we change back to LOADED |
| 156 | + // so that it does not get stuck LOADING/RELOADIN'. |
| 157 | + if (loadAndSet === latestLoadAndSet) { |
| 158 | + setState('LOADED'); |
| 159 | + } |
| 160 | + return get(thisStore); |
| 161 | + } |
| 162 | + throw e; |
| 163 | + } |
| 164 | + }; |
| 165 | + |
| 166 | + currentLoadPromise = loadAndSet(); |
| 167 | + return currentLoadPromise; |
| 168 | + }; |
| 169 | + |
| 170 | + const setStoreValueThenWrite = async ( |
| 171 | + updater: Updater<T>, |
| 172 | + persist?: boolean |
| 173 | + ) => { |
| 174 | + setState('WRITING'); |
| 175 | + let oldValue: T; |
| 176 | + try { |
| 177 | + oldValue = await loadDependenciesThenSet(loadAll); |
| 178 | + } catch { |
| 179 | + oldValue = get(thisStore); |
| 180 | + } |
| 181 | + const newValue = updater(oldValue); |
| 182 | + currentLoadPromise = currentLoadPromise |
| 183 | + .then(() => newValue) |
| 184 | + .catch(() => newValue); |
| 185 | + thisStore.set(newValue); |
| 186 | + |
| 187 | + if (mappingWriteFunction && persist) { |
| 188 | + try { |
| 189 | + const parentValues = await loadAll(stores); |
| 190 | + |
| 191 | + const writeResponse = (await mappingWriteFunction( |
| 192 | + newValue, |
| 193 | + parentValues, |
| 194 | + oldValue |
| 195 | + )) as T; |
| 196 | + |
| 197 | + if (writeResponse !== undefined) { |
| 198 | + thisStore.set(writeResponse); |
| 199 | + currentLoadPromise = currentLoadPromise.then(() => writeResponse); |
| 200 | + } |
| 201 | + } catch (e) { |
| 202 | + logError(e); |
| 203 | + setState('ERROR'); |
| 204 | + throw e; |
| 205 | + } |
| 206 | + } |
| 207 | + setState('LOADED'); |
| 208 | + }; |
| 209 | + |
| 210 | + // required properties |
| 211 | + const subscribe = thisStore.subscribe; |
| 212 | + const set = (newValue: T, persist = true) => |
| 213 | + setStoreValueThenWrite(() => newValue, persist); |
| 214 | + const update = (updater: Updater<T>, persist = true) => |
| 215 | + setStoreValueThenWrite(updater, persist); |
| 216 | + const load = () => loadDependenciesThenSet(loadAll); |
| 217 | + |
| 218 | + // // optional properties |
| 219 | + const hasReloadFunction = Boolean(reloadable || anyReloadable(stores)); |
| 220 | + const reload = hasReloadFunction |
| 221 | + ? async (visitedMap?: VisitedMap) => { |
| 222 | + const visitMap = visitedMap ?? new WeakMap(); |
| 223 | + const reloadAndTrackVisits = (stores: S) => reloadAll(stores, visitMap); |
| 224 | + setState('RELOADING'); |
| 225 | + const result = await loadDependenciesThenSet( |
| 226 | + reloadAndTrackVisits, |
| 227 | + reloadable |
| 228 | + ); |
| 229 | + setState('LOADED'); |
| 230 | + return result; |
| 231 | + } |
| 232 | + : undefined; |
| 233 | + |
| 234 | + const state: Readable<LoadState> = loadState |
| 235 | + ? { subscribe: loadState.subscribe } |
| 236 | + : undefined; |
| 237 | + const reset = getStoreTestingMode() |
| 238 | + ? () => { |
| 239 | + thisStore.set(initial); |
| 240 | + setState('LOADING'); |
| 241 | + loadedValuesString = undefined; |
| 242 | + currentLoadPromise = undefined; |
| 243 | + } |
| 244 | + : undefined; |
| 245 | + |
| 246 | + return { |
| 247 | + get store() { |
| 248 | + return this; |
| 249 | + }, |
| 250 | + subscribe, |
| 251 | + set, |
| 252 | + update, |
| 253 | + load, |
| 254 | + ...(reload && { reload }), |
| 255 | + ...(state && { state }), |
| 256 | + ...(reset && { reset }), |
| 257 | + }; |
| 258 | +}; |
| 259 | + |
| 260 | +/** |
| 261 | + * Generate a Loadable store that is considered 'loaded' after resolving asynchronous behavior. |
| 262 | + * This asynchronous behavior may be derived from the value of parent Loadable or non Loadable stores. |
| 263 | + * If so, this store will begin loading only after the parents have loaded. |
| 264 | + * @param stores Any readable or array of Readables whose value is used to generate the asynchronous behavior of this store. |
| 265 | + * Any changes to the value of these stores post-load will restart the asynchronous behavior of the store using the new values. |
| 266 | + * @param mappingLoadFunction A function that takes in the values of the stores and generates a Promise that resolves |
| 267 | + * to the final value of the store when the asynchronous behavior is complete. |
| 268 | + * @param options Modifiers for store behavior. |
| 269 | + * @returns A Loadable store whose value is set to the resolution of provided async behavior. |
| 270 | + * The loaded value of the store will be ready after awaiting the load function of this store. |
| 271 | + */ |
| 272 | +export const asyncDerived = <S extends Stores, T>( |
| 273 | + stores: S, |
| 274 | + mappingLoadFunction: (values: StoresValues<S>) => Promise<T>, |
| 275 | + options?: AsyncStoreOptions<T> |
| 276 | +): Loadable<T> => { |
| 277 | + const { store, subscribe, load, reload, state, reset } = asyncWritable( |
| 278 | + stores, |
| 279 | + mappingLoadFunction, |
| 280 | + undefined, |
| 281 | + options |
| 282 | + ); |
| 283 | + |
| 284 | + return { |
| 285 | + store, |
| 286 | + subscribe, |
| 287 | + load, |
| 288 | + ...(reload && { reload }), |
| 289 | + ...(state && { state }), |
| 290 | + ...(reset && { reset }), |
| 291 | + }; |
| 292 | +}; |
| 293 | + |
| 294 | +/** |
| 295 | + * Generates a Loadable store that will start asynchronous behavior when subscribed to, |
| 296 | + * and whose value will be equal to the resolution of that behavior when completed. |
| 297 | + * @param initial The initial value of the store before it has loaded or upon load failure. |
| 298 | + * @param loadFunction A function that generates a Promise that resolves to the final value |
| 299 | + * of the store when the asynchronous behavior is complete. |
| 300 | + * @param options Modifiers for store behavior. |
| 301 | + * @returns A Loadable store whose value is set to the resolution of provided async behavior. |
| 302 | + * The loaded value of the store will be ready after awaiting the load function of this store. |
| 303 | + */ |
| 304 | +export const asyncReadable = <T>( |
| 305 | + initial: T, |
| 306 | + loadFunction: () => Promise<T>, |
| 307 | + options?: Omit<AsyncStoreOptions<T>, 'initial'> |
| 308 | +): Loadable<T> => { |
| 309 | + return asyncDerived([], loadFunction, { ...options, initial }); |
| 310 | +}; |
0 commit comments