├── .yarnrc ├── babel.config.js ├── .gitignore ├── .npmrc ├── test ├── tsconfig.json ├── testing.test.ts ├── async-client │ └── index.test.ts ├── utils │ └── index.test.ts ├── standard-stores │ └── index.test.ts ├── persisted │ └── index.test.ts └── async-stores │ └── index.test.ts ├── eslint ├── package.json └── index.js ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── vitest.config.ts ├── src ├── async-client │ ├── types.ts │ └── index.ts ├── persisted │ ├── types.ts │ ├── storage-utils.ts │ └── index.ts ├── config.ts ├── index.ts ├── async-stores │ ├── types.ts │ └── index.ts ├── utils │ └── index.ts └── standard-stores │ └── index.ts ├── .eslintrc.cjs ├── tsconfig.json ├── package.json ├── CHANGELOG.md └── README.md /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org" 2 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { presets: ['@babel/preset-env'] }; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /lib 3 | 4 | yarn-error.log 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | access = "public" 2 | registry = "https://registry.npmjs.org" 3 | 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["../test/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-square-svelte-store", 3 | "version": "1.0.0", 4 | "main": "index.js" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "svelte.svelte-vscode", 4 | "davidanson.vscode-markdownlint", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | include: ['./test/**/*.test.ts'], 7 | environment: 'jsdom', 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/async-client/types.ts: -------------------------------------------------------------------------------- 1 | export type AsyncClient = T extends (...args: infer TArgs) => infer TReturn 2 | ? (...args: TArgs) => Promise 3 | : { 4 | [k in keyof T]: T[k] extends (...args: infer KArgs) => infer KReturn // callable property? 5 | ? (...args: KArgs) => Promise // make the function async 6 | : () => Promise; // return the property in a Promise 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: [ 4 | 'square', 5 | '@typescript-eslint', // add the TypeScript plugin 6 | ], 7 | extends: ['plugin:square/typescript'], 8 | rules: { 9 | 'func-style': ['error', 'expression'], 10 | 'import/extensions': ['error', 'never'], 11 | 'no-console': ['error'], 12 | }, 13 | env: { 14 | browser: true, 15 | node: true, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll": true, 5 | "source.fixAll.markdownlint": true 6 | }, 7 | "svelte.plugin.svelte.format.config.singleQuote": true, 8 | "[svelte]": { 9 | "editor.defaultFormatter": "svelte.svelte-vscode" 10 | }, 11 | "eslint.validate": [ 12 | "javascript", 13 | "svelte", 14 | "typescript", 15 | "yaml" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "node", 5 | "vitest/globals" 6 | ], 7 | "declaration": true, 8 | "outDir": "./lib", 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "target": "ES6", 12 | "moduleResolution": "node", 13 | "module": "es2022", 14 | "strict": false, 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules/*", 21 | "__sapper__/*", 22 | "public/*" 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /src/persisted/types.ts: -------------------------------------------------------------------------------- 1 | import { WritableLoadable } from '../async-stores/types.js'; 2 | 3 | export type StorageType = 'LOCAL_STORAGE' | 'SESSION_STORAGE' | 'COOKIE'; 4 | 5 | export type StorageOptions = { 6 | reloadable?: true; 7 | storageType?: StorageType | string; 8 | consentLevel?: unknown; 9 | }; 10 | 11 | interface Syncable { 12 | resync: () => Promise; 13 | clear: () => Promise; 14 | store: Syncable; 15 | } 16 | 17 | export type Persisted = Syncable & WritableLoadable; 18 | -------------------------------------------------------------------------------- /eslint/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'use-square-svelte-stores': { 4 | meta: { fixable: 'code' }, 5 | create(context) { 6 | return { 7 | ImportDeclaration(node) { 8 | const { source } = node; 9 | if (source.value !== 'svelte/store') { 10 | return; 11 | } 12 | context.report({ 13 | node, 14 | message: 'Import stores from @square/svelte-store', 15 | fix: (fixer) => { 16 | return fixer.replaceText(source, "'@square/svelte-store'"); 17 | }, 18 | }); 19 | }, 20 | }; 21 | }, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // CONFIGURATION OPTIONS 2 | 3 | let testingMode = false; 4 | 5 | let anyStoreCreated = false; 6 | 7 | export const flagStoreCreated = (override = true): void => { 8 | anyStoreCreated = override; 9 | }; 10 | 11 | export const getStoreTestingMode = (): boolean => testingMode; 12 | 13 | export const enableStoreTestingMode = (): void => { 14 | if (anyStoreCreated) { 15 | throw new Error('Testing mode MUST be enabled before store creation'); 16 | } 17 | testingMode = true; 18 | }; 19 | 20 | type ErrorLogger = (e: Error) => void; 21 | let errorLogger: ErrorLogger; 22 | 23 | export const logAsyncErrors = (logger: ErrorLogger): void => { 24 | errorLogger = logger; 25 | }; 26 | 27 | export const logError = (e: Error): void => { 28 | errorLogger?.(e); 29 | }; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@square/svelte-store", 3 | "version": "1.0.18", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "files": [ 7 | "lib/**/*" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/square/svelte-store.git" 12 | }, 13 | "scripts": { 14 | "build": "yarn cleanup && tsc", 15 | "prepublishOnly": "yarn build", 16 | "cleanup": "rm -rf ./lib/", 17 | "lint": "eslint './src/**/*.{js,ts}'", 18 | "test": "vitest run", 19 | "test:watch": "yarn test -- --watch" 20 | }, 21 | "devDependencies": { 22 | "@typescript-eslint/eslint-plugin": "^4.31.2", 23 | "@typescript-eslint/parser": "^4.31.2", 24 | "eslint": "^7.32.0", 25 | "eslint-plugin-square": "^20.0.2", 26 | "jsdom": "^22.1.0", 27 | "svelte": "^4.1.2", 28 | "typescript": "^4.5.5", 29 | "vitest": "^0.34.1" 30 | }, 31 | "dependencies": { 32 | "cookie-storage": "^6.1.0" 33 | }, 34 | "peerDependencies": { 35 | "svelte": ">= 3.0.0 < 5.0.0" 36 | }, 37 | "license": "ISC", 38 | "author": "Square", 39 | "type": "module" 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Svelte Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "yarn", 9 | "runtimeArgs": [ 10 | "test:svelte" 11 | ], 12 | "console": "integratedTerminal", 13 | "internalConsoleOptions": "neverOpen", 14 | "port": 9229 15 | }, 16 | { 17 | "name": "Debug Release Lambda Tests", 18 | "type": "node", 19 | "request": "launch", 20 | "runtimeExecutable": "yarn", 21 | "runtimeArgs": [ 22 | "workspace", 23 | "ecosystem-header-release", 24 | "test" 25 | ], 26 | "console": "integratedTerminal", 27 | "internalConsoleOptions": "neverOpen", 28 | "port": 9230 29 | }, 30 | { 31 | "name": "Debug CLI Tests", 32 | "type": "node", 33 | "request": "launch", 34 | "runtimeExecutable": "yarn", 35 | "runtimeArgs": [ 36 | "workspace", 37 | "ecosystem-header-cli", 38 | "test" 39 | ], 40 | "console": "integratedTerminal", 41 | "internalConsoleOptions": "neverOpen", 42 | "port": 9231 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { get } from 'svelte/store'; 2 | export type { 3 | Readable, 4 | Unsubscriber, 5 | Updater, 6 | StartStopNotifier, 7 | Subscriber, 8 | Writable, 9 | } from 'svelte/store'; 10 | export type { AsyncClient } from './async-client/types.js'; 11 | export type { 12 | LoadState, 13 | Loadable, 14 | Reloadable, 15 | AsyncWritable, 16 | WritableLoadable, 17 | AsyncStoreOptions, 18 | Stores, 19 | StoresValues, 20 | } from './async-stores/types.js'; 21 | export type { 22 | StorageType, 23 | StorageOptions, 24 | Persisted, 25 | } from './persisted/types.js'; 26 | 27 | export { asyncClient } from './async-client/index.js'; 28 | export { 29 | asyncWritable, 30 | asyncDerived, 31 | asyncReadable, 32 | } from './async-stores/index.js'; 33 | export { 34 | configureCustomStorageType, 35 | configurePersistedConsent, 36 | persisted, 37 | } from './persisted/index.js'; 38 | export { derived, readable, writable } from './standard-stores/index.js'; 39 | export { 40 | isLoadable, 41 | isReloadable, 42 | anyLoadable, 43 | anyReloadable, 44 | getAll, 45 | loadAll, 46 | reloadAll, 47 | safeLoad, 48 | rebounce, 49 | } from './utils/index.js'; 50 | export { 51 | getStoreTestingMode, 52 | enableStoreTestingMode, 53 | logAsyncErrors, 54 | } from './config.js'; 55 | -------------------------------------------------------------------------------- /src/persisted/storage-utils.ts: -------------------------------------------------------------------------------- 1 | import { CookieStorage } from 'cookie-storage'; 2 | 3 | const cookieStorage = new CookieStorage(); 4 | 5 | export const getLocalStorageItem = (key: string): unknown => { 6 | const item = window.localStorage.getItem(key); 7 | return item ? JSON.parse(item) : null; 8 | }; 9 | 10 | export const setLocalStorageItem = (key: string, value: unknown): void => { 11 | window.localStorage.setItem(key, JSON.stringify(value)); 12 | }; 13 | 14 | export const removeLocalStorageItem = (key: string): void => { 15 | window.localStorage.removeItem(key); 16 | }; 17 | 18 | export const getSessionStorageItem = (key: string): string | null => { 19 | const item = window.sessionStorage.getItem(key); 20 | return item ? JSON.parse(item) : null; 21 | }; 22 | 23 | export const setSessionStorageItem = (key: string, value: unknown): void => { 24 | window.sessionStorage.setItem(key, JSON.stringify(value)); 25 | }; 26 | 27 | export const removeSessionStorageItem = (key: string): void => { 28 | window.sessionStorage.removeItem(key); 29 | }; 30 | 31 | export const getCookie = (key: string): unknown => { 32 | const item = cookieStorage.getItem(key); 33 | return item ? JSON.parse(item) : null; 34 | }; 35 | 36 | export const setCookie = (key: string, value: unknown): void => { 37 | cookieStorage.setItem(key, JSON.stringify(value)); 38 | }; 39 | 40 | export const removeCookie = (key: string): void => { 41 | cookieStorage.removeItem(key); 42 | }; 43 | -------------------------------------------------------------------------------- /src/async-stores/types.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Updater, Writable } from 'svelte/store'; 2 | 3 | export type State = 'LOADING' | 'LOADED' | 'RELOADING' | 'ERROR' | 'WRITING'; 4 | 5 | export type LoadState = { 6 | isLoading: boolean; 7 | isReloading: boolean; 8 | isLoaded: boolean; 9 | isWriting: boolean; 10 | isError: boolean; 11 | isPending: boolean; // LOADING or RELOADING 12 | isSettled: boolean; // LOADED or ERROR 13 | }; 14 | 15 | export type VisitedMap = WeakMap, Promise>; 16 | 17 | export interface Loadable extends Readable { 18 | load(): Promise; 19 | reload?(visitedMap?: VisitedMap): Promise; 20 | state?: Readable; 21 | reset?(): void; 22 | store: Loadable; 23 | } 24 | 25 | export interface Reloadable extends Loadable { 26 | reload(visitedMap?: VisitedMap): Promise; 27 | } 28 | 29 | export interface AsyncWritable extends Writable { 30 | set(value: T, persist?: boolean): Promise; 31 | update(updater: Updater): Promise; 32 | store: AsyncWritable; 33 | } 34 | 35 | export type WritableLoadable = Loadable & AsyncWritable; 36 | 37 | export interface AsyncStoreOptions { 38 | reloadable?: true; 39 | trackState?: true; 40 | initial?: T; 41 | } 42 | export declare type StoresArray = 43 | | [Readable, ...Array>] 44 | | Array>; 45 | /* These types come from Svelte but are not exported, so copying them here */ 46 | /* One or more `Readable`s. */ 47 | export declare type Stores = Readable | StoresArray; 48 | 49 | export declare type ValuesArray = { 50 | [K in keyof T]: T[K] extends Readable ? U : never; 51 | }; 52 | 53 | /** One or more values from `Readable` stores. */ 54 | export declare type StoresValues = T extends Readable 55 | ? U 56 | : ValuesArray; 57 | -------------------------------------------------------------------------------- /test/testing.test.ts: -------------------------------------------------------------------------------- 1 | import { flagStoreCreated } from '../src/config'; 2 | import { 3 | asyncReadable, 4 | get, 5 | derived, 6 | enableStoreTestingMode, 7 | readable, 8 | asyncClient, 9 | asyncWritable, 10 | asyncDerived, 11 | persisted, 12 | writable, 13 | } from '../src/index'; 14 | 15 | enableStoreTestingMode(); 16 | 17 | const mockedFetch = vi.fn(); 18 | const myReadable = asyncReadable('initial', () => mockedFetch()); 19 | 20 | beforeEach(() => { 21 | myReadable.reset(); 22 | }); 23 | 24 | describe('can be reset for different tests', () => { 25 | it('loads resolution', async () => { 26 | mockedFetch.mockResolvedValueOnce('loaded'); 27 | await myReadable.load(); 28 | 29 | expect(get(myReadable)).toBe('loaded'); 30 | 31 | mockedFetch.mockRejectedValueOnce('rejected'); 32 | await myReadable.load(); 33 | 34 | expect(get(myReadable)).toBe('loaded'); 35 | }); 36 | 37 | it('loads rejection', async () => { 38 | mockedFetch.mockRejectedValueOnce('rejected'); 39 | await myReadable.load().catch(() => Promise.resolve()); 40 | 41 | expect(get(myReadable)).toBe('initial'); 42 | 43 | mockedFetch.mockResolvedValueOnce('loaded'); 44 | await myReadable.load().catch(() => Promise.resolve()); 45 | 46 | expect(get(myReadable)).toBe('initial'); 47 | }); 48 | }); 49 | 50 | describe('asyncClient', () => { 51 | it('can spy on client properties', async () => { 52 | const myClient = asyncClient(readable({ myFunc: () => 'some string' })); 53 | const myFuncSpy = vi.spyOn(myClient, 'myFunc'); 54 | 55 | expect(myFuncSpy).toHaveBeenCalledTimes(0); 56 | 57 | const result = await myClient.myFunc(); 58 | 59 | expect(result).toBe('some string'); 60 | expect(myFuncSpy).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | it('can mock multiple async clients', () => { 64 | const clientA = asyncClient( 65 | readable<{ myFunc: () => string }>({ myFunc: () => 'clientA' }) 66 | ); 67 | const clientB = asyncClient(readable({ myFunc: () => 'clientB' })); 68 | 69 | const subscribe = vi.spyOn(clientA, 'subscribe'); 70 | subscribe.mockImplementation((callbackFn) => { 71 | callbackFn({ myFunc: () => 'mockedA' }); 72 | return vi.fn(); 73 | }); 74 | 75 | expect(get(clientA).myFunc()).toBe('mockedA'); 76 | expect(get(clientB).myFunc()).toBe('clientB'); 77 | }); 78 | }); 79 | 80 | describe('enableStoreTestingMode', () => { 81 | it('throws when store already created', () => { 82 | [ 83 | asyncWritable, 84 | asyncDerived, 85 | asyncReadable, 86 | derived, 87 | writable, 88 | readable, 89 | persisted, 90 | ].forEach((store: CallableFunction) => { 91 | flagStoreCreated(false); 92 | store([], vi.fn); 93 | expect(() => enableStoreTestingMode()).toThrowError(); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/async-client/index.ts: -------------------------------------------------------------------------------- 1 | import { Loadable, StoresValues } from '../async-stores/types.js'; 2 | import { AsyncClient } from './types.js'; 3 | import { get } from 'svelte/store'; 4 | 5 | /** 6 | * Generates an AsyncClient from a Loadable store. The AsyncClient will have all 7 | * of the properties of the input store, plus a collection of asynchronous functions 8 | * for kicking off access of the store's value's properties before it has finished loading. 9 | * i.e. an asyncClient that loads to {foo: 'bar'} will have a `foo` function that 10 | * resolves to 'bar' when the store has loaded. 11 | * @param loadable Loadable to unpack into an asnycClient 12 | * @returns an asyncClient with the properties of the input store and asynchronous 13 | * accessors to the properties of the store's loaded value 14 | */ 15 | export const asyncClient = >( 16 | loadable: S 17 | ): S & AsyncClient> => { 18 | // Generate an empty function that will be proxied. 19 | // This lets us invoke the resulting asyncClient. 20 | // An anonymous function is used instead of the function prototype 21 | // so that testing environments can tell asyncClients apart. 22 | const emptyFunction = () => { 23 | /* no op*/ 24 | }; 25 | return new Proxy(emptyFunction, { 26 | get: (proxiedFunction, property) => { 27 | if (proxiedFunction[property]) { 28 | // this ensures that vitest is able to identify the proxy 29 | // when setting up spies on its properties 30 | return proxiedFunction[property]; 31 | } 32 | if (loadable[property]) { 33 | return loadable[property]; 34 | } 35 | return async (...argumentsList: unknown[]) => { 36 | const storeValue = await loadable.load(); 37 | const original = storeValue[property]; 38 | if (typeof original === 'function') { 39 | return Reflect.apply(original, storeValue, argumentsList); 40 | } else { 41 | return original; 42 | } 43 | }; 44 | }, 45 | apply: async (_, __, argumentsList) => { 46 | const storeValue = await loadable.load(); 47 | if (typeof storeValue === 'function') { 48 | return Reflect.apply(storeValue, storeValue, argumentsList); 49 | } 50 | return storeValue; 51 | }, 52 | set: (proxiedFunction, property, value) => { 53 | return Reflect.set(loadable, property, value); 54 | }, 55 | defineProperty(proxiedFunction, property, value) { 56 | return Reflect.defineProperty(loadable, property, value); 57 | }, 58 | has: (proxiedFunction, property) => { 59 | if (property in proxiedFunction) { 60 | return true; 61 | } 62 | 63 | if (property in loadable) { 64 | return true; 65 | } 66 | 67 | const value = get(loadable); 68 | 69 | if (value && value instanceof Object) { 70 | // eslint-disable-next-line @typescript-eslint/ban-types 71 | return property in (value as object); 72 | } 73 | return false; 74 | }, 75 | }) as unknown as S & AsyncClient>; 76 | }; 77 | -------------------------------------------------------------------------------- /test/async-client/index.test.ts: -------------------------------------------------------------------------------- 1 | import { asyncClient, readable, writable } from '../../src'; 2 | 3 | describe('asyncClient', () => { 4 | it('allows access of store value functions', async () => { 5 | const myClient = asyncClient(readable({ add1: (num: number) => num + 1 })); 6 | const result = await myClient.add1(1); 7 | expect(result).toBe(2); 8 | }); 9 | 10 | it('allows access of store value functions before loading', async () => { 11 | const myClient = asyncClient(writable<{ add1: (num: number) => number }>()); 12 | const resultPromise = myClient.add1(1); 13 | myClient.set({ add1: (num: number) => num + 1 }); 14 | const result = await resultPromise; 15 | expect(result).toBe(2); 16 | }); 17 | 18 | it('allows access of non-function store value properties', async () => { 19 | const myClient = asyncClient(writable<{ foo: string }>()); 20 | const resultPromise = myClient.foo(); 21 | myClient.set({ foo: 'bar' }); 22 | const result = await resultPromise; 23 | expect(result).toBe('bar'); 24 | }); 25 | 26 | it('allows invocation of function stores', async () => { 27 | const myClient = asyncClient(writable<(input: string) => string>()); 28 | const resultPromise = myClient('input'); 29 | myClient.set((input: string) => `${input} + output`); 30 | const result = await resultPromise; 31 | expect(result).toBe('input + output'); 32 | }); 33 | 34 | describe("'in' operator", () => { 35 | it('correctly identifies the existence of own properties', () => { 36 | const myClient = asyncClient(writable()); 37 | 38 | expect('foo' in myClient).toBe(false); 39 | 40 | myClient.set({ foo: true }); 41 | 42 | expect('foo' in myClient).toBe(true); 43 | }); 44 | 45 | it('correctly identifies the existence of inherited properties', () => { 46 | const myClient = asyncClient(writable()); 47 | 48 | expect('foo' in myClient).toBe(false); 49 | expect('bar' in myClient).toBe(false); 50 | 51 | class MyClass { 52 | foo = true; 53 | } 54 | 55 | class MyChildClass extends MyClass { 56 | bar = true; 57 | } 58 | 59 | myClient.set(new MyChildClass()); 60 | 61 | expect('foo' in myClient).toBe(true); 62 | expect('bar' in myClient).toBe(true); 63 | }); 64 | }); 65 | 66 | describe('Setting properties', () => { 67 | type MyClient = { 68 | foo: boolean; 69 | bar?: () => void; 70 | }; 71 | 72 | it("'set' proxy handler", () => { 73 | const myMock = vi.fn(); 74 | 75 | const myWritable = writable(); 76 | 77 | const myClient = asyncClient(myWritable); 78 | 79 | myClient.set({ foo: true }); 80 | 81 | myClient.bar = myMock; 82 | 83 | expect(Object.prototype.hasOwnProperty.call(myWritable, 'bar')).toBe( 84 | true 85 | ); 86 | expect('bar' in myClient).toBe(true); 87 | 88 | myClient.bar(); 89 | 90 | expect(myMock).toHaveBeenCalled(); 91 | }); 92 | 93 | it("'defineProperty' proxy handler", () => { 94 | const myMock = vi.fn(); 95 | 96 | const myWritable = writable(); 97 | 98 | const myClient = asyncClient(myWritable); 99 | 100 | myClient.set({ foo: true }); 101 | 102 | Object.defineProperty(myClient, 'bar', { value: myMock }); 103 | 104 | expect(Object.prototype.hasOwnProperty.call(myWritable, 'bar')).toBe( 105 | true 106 | ); 107 | expect('bar' in myClient).toBe(true); 108 | 109 | myClient.bar(); 110 | 111 | expect(myMock).toHaveBeenCalled(); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog # 2 | 3 | ## 1.0.17 (2023-6-20) 4 | 5 | - *BREAKING CHANGE* chore: rearrange dependencies to minimize installed package size 6 | - How to migrate: 7 | - Whereas before square/svelte-store came with its own version of svelte, now it is a peer dependency. This means that this package will now use the existing version of svelte instead of its own, which may result in behavior discrepancies if using an old version of svelte. This can be mitigated by updating the version of svelte used. 8 | 9 | ## 1.0.16 (2023-6-20) 10 | 11 | - fix: moduleResoltion: NoodeNext support 12 | - feat: allow for custom storage types for persisted stores 13 | 14 | ## 1.0.15 (2023-2-27) 15 | 16 | - *BREAKING CHANGE* fix: loadAll/reloadAll resolve with up-to-date store values 17 | - How to migrate: 18 | - Using loadAll/reloadAll with a single store (not an array) now resolves to a single value (not in an array). If you are indexing into the array, this must be changed to use the value directly. 19 | - When loadAll/reloadAll resolves it gives you the store values at that moment. Previously it would give you the values of the stores current load process at the time loadAll/reloadAll was called. This means updates made to stores while loadAll/reloadAll is still pending will now be reflected in the resolved values. If you wish to avoid this behavior for specific stores, you must now load them individually. 20 | - feat: throw an error when testing mode enabled after store creation 21 | 22 | ## 1.0.14 (2023-2-10) 23 | 24 | - fix: add additional property support for asyncClients to allow for better mocking/testing 25 | 26 | ## 1.0.13 (2023-1-6) 27 | 28 | - fix: allow asyncClients to be seperately mocked 29 | - use cookie-storage instead js-cookie 30 | 31 | ## 1.0.12 (2022-12-23) 32 | 33 | - fix: reloading child with mutliple routes to ancestor now reloads ancestor once 34 | 35 | ## 1.0.11 (2022-11-08) 36 | 37 | -fix: writable with single parent calls setter with array 38 | 39 | ## 1.0.10 (2022-11-07) 40 | 41 | - *BREAKING CHANGE* feat: stores now take an options object instead of separate parameters for reloadable and initial 42 | - How to migrate: async stores that have been marked as reloadable, or have been given a non default initial value, must be changed to receive an options object as the last parameter with the following format: `{ reloadable: true, initial: someValue }` 43 | - feat: add persisted stores (synchronized value with storage items or cookies) 44 | - feat: add asyncClient stores (proxies async stores so their values can be interacted with before loading) 45 | - feat: add logAsyncErrors configuration function (allows automatic logging of async load errors) 46 | - feat: add track state feature for async stores. `trackState` can be provided as an option upon store creation. This will generate a second store that can be used for reactive conditional rendering based on the primary store's load state. 47 | - fix: loading a store will ensure that there is a subscriber to that store for the duration of the load process. This ensures that the `start` function of the store, or of any parent stores, is still run if the store is loaded without any other active subscribers. It additionally ensures that derived stores receive value updates from any changes to parents. 48 | - *BREAKING CHANGE* feat: `flagForReload` replaced by `reset` function. Reset puts the store in its initial state when reset is called, rather than upon next load of the store like flagForReload. 49 | - How to migrate: change usages of `flagForReload()` to `reset()`; 50 | - feat: async stores support fetch aborts via abort controllers. Fetch requests in an async store's load function can be aborted using an abort controller to prevent the store's value from updating without resulting in a load rejection. 51 | - feat: add `rebounce` function. `rebounce` wraps an async function to automatically abort any in-flight calls to that function when a new call is made. This can be used in a store's load function to prevent race condition bugs that can arise from multiple near-concurrent updates. 52 | 53 | ## 0.2.3 (2022-09-20) 54 | 55 | - fix: loading a readable/writable store will run start function 56 | 57 | ## 0.2.2 (2022-09-20) 58 | 59 | - fix: unsubscribing from async stores now correctly unsubscribes from parents 60 | - chore: add type guards to output of loadable checks 61 | 62 | ## 0.2.0 (2022-07-09) 63 | 64 | - *BREAKING CHANGE* feat: add load functionality to readable/writable stores 65 | - readable and writable stores now include a `load` function, the same as the other stores. 66 | - This load function resolves when the store is first `set`. 67 | - If the store is given an initial value that is not undefined, it will `load` immeadietly. 68 | - How this might break your app: derived / asyncDerived stores only `load` after every parent has loaded. If you derive from a readable or writable store, the derived store will now only load after the readable or writable store has loaded. 69 | - How to migrate: If you want a readable / writable store to load before it has been given a final value, initialize the store with a value of 'null' rather than undefined. 70 | 71 | ## 0.1.5 (2022-06-16) 72 | 73 | - feat: safeLoad function for catching load errors 74 | 75 | ## 0.1.3 (2022-05-08) 76 | 77 | - fix: remove non-functional isLoading property 78 | - feat: add testing mode 79 | - feat: asyncWritable mappingWriteFunction passed previous value of store 80 | -------------------------------------------------------------------------------- /test/utils/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | asyncReadable, 3 | Loadable, 4 | readable, 5 | loadAll, 6 | rebounce, 7 | reloadAll, 8 | safeLoad, 9 | enableStoreTestingMode, 10 | } from '../../src'; 11 | 12 | enableStoreTestingMode(); 13 | 14 | describe('loadAll / reloadAll utils', () => { 15 | const mockReload = vi.fn(); 16 | const myLoadable = asyncReadable(undefined, () => Promise.resolve('loaded')); 17 | const myReloadable = asyncReadable(undefined, mockReload, { 18 | reloadable: true, 19 | }); 20 | const badLoadable = { 21 | load: () => Promise.reject(new Error('E')), 22 | reload: () => Promise.reject(new Error('F')), 23 | } as Loadable; 24 | 25 | beforeEach(() => { 26 | mockReload 27 | .mockResolvedValueOnce('first value') 28 | .mockResolvedValueOnce('second value') 29 | .mockResolvedValueOnce('third value'); 30 | }); 31 | 32 | afterEach(() => { 33 | mockReload.mockReset(); 34 | myReloadable.reset(); 35 | }); 36 | 37 | describe('loadAll function', () => { 38 | it('loads single store', () => { 39 | expect(loadAll(myLoadable)).resolves.toStrictEqual('loaded'); 40 | }); 41 | 42 | it('resolves to values of all stores', () => { 43 | expect(loadAll([myLoadable, myReloadable])).resolves.toStrictEqual([ 44 | 'loaded', 45 | 'first value', 46 | ]); 47 | expect(true).toBeTruthy(); 48 | }); 49 | 50 | it('handles rejection', () => { 51 | expect(loadAll([myLoadable, badLoadable])).rejects.toStrictEqual( 52 | new Error('E') 53 | ); 54 | }); 55 | }); 56 | 57 | describe('reloadAll function', () => { 58 | it('reloads loads single store', async () => { 59 | await loadAll(myReloadable); 60 | expect(reloadAll(myReloadable)).resolves.toStrictEqual('second value'); 61 | }); 62 | 63 | it('reloads and resolves to values of all stores', async () => { 64 | await loadAll([myLoadable, myReloadable]); 65 | expect(reloadAll([myLoadable, myReloadable])).resolves.toStrictEqual([ 66 | 'loaded', 67 | 'second value', 68 | ]); 69 | }); 70 | 71 | it('handles rejection', () => { 72 | expect(reloadAll([myLoadable, badLoadable])).rejects.toStrictEqual( 73 | new Error('F') 74 | ); 75 | }); 76 | 77 | it('does not reload already visited store', () => { 78 | const visitedMap = new WeakMap(); 79 | visitedMap.set(myReloadable, myReloadable.reload()); 80 | expect(reloadAll(myReloadable, visitedMap)).resolves.toStrictEqual( 81 | 'first value' 82 | ); 83 | }); 84 | }); 85 | 86 | describe('safeLoad function', () => { 87 | it('resolves to true with good store', () => { 88 | expect(safeLoad(myLoadable)).resolves.toBe(true); 89 | }); 90 | 91 | it('resolves to false with bad store', () => { 92 | expect(safeLoad(badLoadable)).resolves.toBe(false); 93 | }); 94 | }); 95 | }); 96 | 97 | describe('rebounce', () => { 98 | const abortError = new DOMException( 99 | 'The function was rebounced.', 100 | 'AbortError' 101 | ); 102 | const interval = vi.fn(); 103 | 104 | beforeEach(() => { 105 | interval.mockReset(); 106 | interval 107 | .mockReturnValueOnce(100) 108 | .mockReturnValueOnce(80) 109 | .mockReturnValueOnce(60) 110 | .mockReturnValue(10); 111 | }); 112 | 113 | const toUpperCase = (input: string) => input.toUpperCase(); 114 | 115 | const asyncToUpperCase = (input: string) => { 116 | return new Promise((resolve) => { 117 | setTimeout(() => { 118 | resolve(input.toUpperCase()); 119 | }, interval()); 120 | }); 121 | }; 122 | 123 | it('works with no timer or rejects', () => { 124 | const rebouncedToUpperCase = rebounce(asyncToUpperCase); 125 | 126 | expect(rebouncedToUpperCase('some')).rejects.toStrictEqual(abortError); 127 | expect(rebouncedToUpperCase('lowercase')).rejects.toStrictEqual(abortError); 128 | expect(rebouncedToUpperCase('strings')).resolves.toBe('STRINGS'); 129 | }); 130 | 131 | it('can be called after resolving', async () => { 132 | const rebouncedToUpperCase = rebounce(asyncToUpperCase); 133 | 134 | expect(rebouncedToUpperCase('some')).rejects.toStrictEqual(abortError); 135 | const result = await rebouncedToUpperCase('lowercase'); 136 | expect(result).toBe('LOWERCASE'); 137 | 138 | expect(rebouncedToUpperCase('strings')).resolves.toBe('STRINGS'); 139 | }); 140 | 141 | it('works with timer', () => { 142 | const getValue = vi 143 | .fn() 144 | .mockReturnValueOnce('one') 145 | .mockReturnValueOnce('two') 146 | .mockReturnValueOnce('more'); 147 | const rebouncedGetValue = rebounce(getValue, 100); 148 | 149 | expect(rebouncedGetValue()).rejects.toStrictEqual(abortError); 150 | expect(rebouncedGetValue()).rejects.toStrictEqual(abortError); 151 | expect(rebouncedGetValue()).resolves.toStrictEqual('one'); 152 | }); 153 | 154 | it('passes through rejections', () => { 155 | const someError = new Error('some error'); 156 | const rebouncedRejection = rebounce( 157 | (_: string) => Promise.reject(someError), 158 | 100 159 | ); 160 | 161 | expect(rebouncedRejection('some')).rejects.toStrictEqual(abortError); 162 | expect(rebouncedRejection('lowercase')).rejects.toStrictEqual(abortError); 163 | expect(rebouncedRejection('strings')).rejects.toStrictEqual(someError); 164 | }); 165 | 166 | it('can be cleared', () => { 167 | const rebouncedToUpperCase = rebounce(toUpperCase, 100); 168 | 169 | expect(rebouncedToUpperCase('a string')).rejects.toStrictEqual(abortError); 170 | rebouncedToUpperCase.clear(); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store'; 2 | import { 3 | StoresArray, 4 | VisitedMap, 5 | type Loadable, 6 | type Reloadable, 7 | type Stores, 8 | type StoresValues, 9 | } from '../async-stores/types.js'; 10 | 11 | export const getStoresArray = (stores: Stores): StoresArray => { 12 | return Array.isArray(stores) ? stores : [stores]; 13 | }; 14 | 15 | export const isLoadable = (object: unknown): object is Loadable => 16 | object ? Object.prototype.hasOwnProperty.call(object, 'load') : false; 17 | 18 | export const isReloadable = (object: unknown): object is Reloadable => 19 | object ? Object.prototype.hasOwnProperty.call(object, 'reload') : false; 20 | 21 | export const anyLoadable = (stores: Stores): boolean => 22 | getStoresArray(stores).some(isLoadable); 23 | 24 | export const anyReloadable = (stores: Stores): boolean => 25 | getStoresArray(stores).some(isReloadable); 26 | 27 | export const getAll = (stores: S): StoresValues => { 28 | const valuesArray = getStoresArray(stores).map((store) => 29 | get(store) 30 | ) as unknown as StoresValues; 31 | return Array.isArray(stores) ? valuesArray : valuesArray[0]; 32 | }; 33 | 34 | /** 35 | * Load a number of Stores. Loading a store will first await loadAll of any parents. 36 | * @param stores Any Readable or array of Readables to await loading of. 37 | * @returns Promise that resolves to an array of the loaded values of the input stores. 38 | * Non Loadables will resolve immediately. 39 | */ 40 | export const loadAll = async ( 41 | stores: S 42 | ): Promise> => { 43 | const loadPromises = getStoresArray(stores).map((store) => { 44 | if (Object.prototype.hasOwnProperty.call(store, 'load')) { 45 | return (store as Loadable).load(); 46 | } else { 47 | return get(store); 48 | } 49 | }); 50 | 51 | await Promise.all(loadPromises); 52 | 53 | return getAll(stores); 54 | }; 55 | 56 | /** 57 | * Reload a number of stores. Reloading a store will first await reloadAll of any parents. 58 | * If a store has no ancestors that are flagged as reloadable, reloading is equivalent to loading. 59 | * @param stores Any Readable or array of Readables to await reloading of. 60 | * Reloading a store will first await reloadAll of any parents. 61 | * @returns Promise that resolves to an array of the loaded values of the input stores. 62 | * Non Loadables will resolve immediately. 63 | */ 64 | export const reloadAll = async ( 65 | stores: S, 66 | visitedMap?: VisitedMap 67 | ): Promise> => { 68 | const visitMap = visitedMap ?? new WeakMap(); 69 | 70 | const reloadPromises = getStoresArray(stores).map((store) => { 71 | if (Object.prototype.hasOwnProperty.call(store, 'reload')) { 72 | // only reload if store has not already been visited 73 | if (!visitMap.has(store)) { 74 | visitMap.set(store, (store as Loadable).reload(visitMap)); 75 | } 76 | return visitMap.get(store); 77 | } else if (Object.prototype.hasOwnProperty.call(store, 'load')) { 78 | return (store as Loadable).load(); 79 | } else { 80 | return get(store); 81 | } 82 | }); 83 | 84 | await Promise.all(reloadPromises); 85 | 86 | return getAll(stores); 87 | }; 88 | 89 | /** 90 | * Load a number of stores, and catch any errors. 91 | * @param stores Any Readable or array of Readables to await loading of. 92 | * @returns boolean representing whether the given stores loaded without errors, or not. 93 | */ 94 | export const safeLoad = async ( 95 | stores: S 96 | ): Promise => { 97 | try { 98 | await loadAll(stores); 99 | return true; 100 | } catch { 101 | return false; 102 | } 103 | }; 104 | 105 | type FlatPromise = T extends Promise ? T : Promise; 106 | 107 | /** 108 | * Create a rebounced version of a provided function. The rebounced function resolves to the 109 | * returned value of the original function, or rejects with an AbortError is the rebounced 110 | * function is called again before resolution. 111 | * @param callback The function to be rebounced. 112 | * @param delay Adds millisecond delay upon rebounced function call before original function 113 | * is called. Successive calls within this period create rejection without calling original function. 114 | * @returns Rebounced version of proivded callback function. 115 | */ 116 | export const rebounce = ( 117 | callback: (...args: T[]) => U, 118 | delay = 0 119 | ): ((...args: T[]) => FlatPromise) & { 120 | clear: () => void; 121 | } => { 122 | let previousReject: (reason: Error) => void; 123 | let existingTimer: ReturnType; 124 | 125 | const rebounced = (...args: T[]): FlatPromise => { 126 | previousReject?.( 127 | new DOMException('The function was rebounced.', 'AbortError') 128 | ); 129 | let currentResolve: (value: U | PromiseLike) => void; 130 | let currentReject: (reason: Error) => void; 131 | 132 | const currentPromise = new Promise((resolve, reject) => { 133 | currentResolve = resolve; 134 | currentReject = reject; 135 | }) as U extends Promise ? U : Promise; 136 | 137 | const resolveCallback = async () => { 138 | try { 139 | const result = await Promise.resolve(callback(...args)); 140 | currentResolve(result); 141 | } catch (error) { 142 | currentReject(error); 143 | } 144 | }; 145 | 146 | clearTimeout(existingTimer); 147 | existingTimer = setTimeout(resolveCallback, delay); 148 | 149 | previousReject = currentReject; 150 | 151 | return currentPromise; 152 | }; 153 | 154 | const clear = () => { 155 | clearTimeout(existingTimer); 156 | previousReject?.( 157 | new DOMException('The function was rebounced.', 'AbortError') 158 | ); 159 | existingTimer = undefined; 160 | previousReject = undefined; 161 | }; 162 | 163 | rebounced.clear = clear; 164 | 165 | return rebounced; 166 | }; 167 | -------------------------------------------------------------------------------- /src/persisted/index.ts: -------------------------------------------------------------------------------- 1 | import { get, type Updater, type Subscriber } from 'svelte/store'; 2 | import { StorageType, type StorageOptions, type Persisted } from './types.js'; 3 | import type { Loadable } from '../async-stores/types.js'; 4 | import { isLoadable, reloadAll } from '../utils/index.js'; 5 | import { writable } from '../standard-stores/index.js'; 6 | import { 7 | getCookie, 8 | getLocalStorageItem, 9 | getSessionStorageItem, 10 | setCookie, 11 | setSessionStorageItem, 12 | setLocalStorageItem, 13 | removeSessionStorageItem, 14 | removeCookie, 15 | removeLocalStorageItem, 16 | } from './storage-utils.js'; 17 | 18 | type GetStorageItem = (key: string) => unknown; 19 | type SetStorageItem = (key: string, value: unknown) => void; 20 | type RemoveStorageItem = (key: string) => void; 21 | 22 | type StorageFunctions = { 23 | getStorageItem: GetStorageItem; 24 | setStorageItem: SetStorageItem; 25 | removeStorageItem: RemoveStorageItem; 26 | }; 27 | 28 | const builtinStorageFunctions: Record = { 29 | LOCAL_STORAGE: { 30 | getStorageItem: getLocalStorageItem, 31 | setStorageItem: setLocalStorageItem, 32 | removeStorageItem: removeLocalStorageItem, 33 | }, 34 | SESSION_STORAGE: { 35 | getStorageItem: getSessionStorageItem, 36 | setStorageItem: setSessionStorageItem, 37 | removeStorageItem: removeSessionStorageItem, 38 | }, 39 | COOKIE: { 40 | getStorageItem: getCookie, 41 | setStorageItem: setCookie, 42 | removeStorageItem: removeCookie, 43 | }, 44 | }; 45 | 46 | const customStorageFunctions: Record = {}; 47 | 48 | export const configureCustomStorageType = ( 49 | type: string, 50 | storageFunctions: StorageFunctions 51 | ): void => { 52 | customStorageFunctions[type] = storageFunctions; 53 | }; 54 | 55 | const getStorageFunctions = (type: StorageType | string): StorageFunctions => { 56 | const storageFunctions = { 57 | ...builtinStorageFunctions, 58 | ...customStorageFunctions, 59 | }[type]; 60 | if (!storageFunctions) { 61 | throw new Error(`'${type}' is not a valid StorageType!`); 62 | } 63 | return storageFunctions; 64 | }; 65 | 66 | type ConsentChecker = (consentLevel: unknown) => boolean; 67 | 68 | let checkConsent: ConsentChecker; 69 | 70 | export const configurePersistedConsent = ( 71 | consentChecker: ConsentChecker 72 | ): void => { 73 | checkConsent = consentChecker; 74 | }; 75 | 76 | /** 77 | * Creates a `Writable` store that synchronizes with a localStorage item, 78 | * sessionStorage item, or cookie. The store's value will initialize to the value of 79 | * the corresponding storage item if found, otherwise it will use the provided initial 80 | * value and persist that value in storage. Any changes to the value of this store will 81 | * be persisted in storage. 82 | * @param initial The value to initialize to when used when a corresponding storage 83 | * item is not found. If a Loadable store is provided the store will be loaded and its value 84 | * used in this case. 85 | * @param key The key of the storage item to synchronize. 86 | * @param options Modifiers for store behavior. 87 | */ 88 | export const persisted = ( 89 | initial: T | Loadable, 90 | key: string | (() => Promise), 91 | options: StorageOptions = {} 92 | ): Persisted => { 93 | const { reloadable, storageType, consentLevel } = options; 94 | 95 | const { getStorageItem, setStorageItem, removeStorageItem } = 96 | getStorageFunctions(storageType || 'LOCAL_STORAGE'); 97 | 98 | const getKey = () => { 99 | if (typeof key === 'function') { 100 | return key(); 101 | } 102 | return Promise.resolve(key); 103 | }; 104 | 105 | const setAndPersist = async (value: T, set: Subscriber) => { 106 | // check consent if checker provided 107 | if (!checkConsent || checkConsent(consentLevel)) { 108 | const storageKey = await getKey(); 109 | setStorageItem(storageKey, value); 110 | } 111 | set(value); 112 | }; 113 | 114 | const synchronize = async (set: Subscriber): Promise => { 115 | const storageKey = await getKey(); 116 | const stored = getStorageItem(storageKey); 117 | 118 | if (stored) { 119 | set(stored as T); 120 | return stored as T; 121 | } else if (initial !== undefined) { 122 | if (isLoadable(initial)) { 123 | const $initial = await initial.load(); 124 | await setAndPersist($initial, set); 125 | 126 | return $initial; 127 | } else { 128 | await setAndPersist(initial, set); 129 | 130 | return initial; 131 | } 132 | } else { 133 | set(undefined); 134 | return undefined; 135 | } 136 | }; 137 | 138 | let initialSync: Promise; 139 | 140 | const thisStore = writable(undefined, (set) => { 141 | initialSync = synchronize(set); 142 | }); 143 | 144 | const subscribe = thisStore.subscribe; 145 | 146 | const set = async (value: T) => { 147 | await initialSync; 148 | return setAndPersist(value, thisStore.set); 149 | }; 150 | 151 | const update = async (updater: Updater) => { 152 | await (initialSync ?? synchronize(thisStore.set)); 153 | const newValue = updater(get(thisStore)); 154 | await setAndPersist(newValue, thisStore.set); 155 | }; 156 | 157 | const load = thisStore.load; 158 | 159 | const resync = async (): Promise => { 160 | await initialSync; 161 | return synchronize(thisStore.set); 162 | }; 163 | 164 | const clear = async () => { 165 | const storageKey = await getKey(); 166 | removeStorageItem(storageKey); 167 | thisStore.set(null); 168 | }; 169 | 170 | const reload = reloadable 171 | ? async () => { 172 | let newValue: T; 173 | 174 | if (isLoadable(initial)) { 175 | [newValue] = await reloadAll([initial]); 176 | } else { 177 | newValue = initial; 178 | } 179 | 180 | setAndPersist(newValue, thisStore.set); 181 | return newValue; 182 | } 183 | : undefined; 184 | 185 | return { 186 | get store() { 187 | return this; 188 | }, 189 | subscribe, 190 | set, 191 | update, 192 | load, 193 | resync, 194 | clear, 195 | ...(reload && { reload }), 196 | }; 197 | }; 198 | -------------------------------------------------------------------------------- /src/standard-stores/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | derived as vanillaDerived, 3 | get, 4 | type Readable, 5 | type StartStopNotifier, 6 | type Subscriber, 7 | type Unsubscriber, 8 | type Updater, 9 | type Writable, 10 | writable as vanillaWritable, 11 | } from 'svelte/store'; 12 | import { anyReloadable, loadAll, reloadAll } from '../utils/index.js'; 13 | import type { 14 | Loadable, 15 | Stores, 16 | StoresValues, 17 | VisitedMap, 18 | } from '../async-stores/types.js'; 19 | import { flagStoreCreated } from '../config.js'; 20 | 21 | const loadDependencies = async ( 22 | thisStore: Readable, 23 | loadFunction: (stores: S) => Promise, 24 | stores: S 25 | ): Promise => { 26 | // Create a dummy subscription when we load the store. 27 | // This ensures that we will have at least one subscriber when 28 | // loading the store so that our start function will run. 29 | const dummyUnsubscribe = thisStore.subscribe(() => { 30 | /* no-op */ 31 | }); 32 | try { 33 | await loadFunction(stores); 34 | } catch (error) { 35 | dummyUnsubscribe(); 36 | throw error; 37 | } 38 | dummyUnsubscribe(); 39 | return get(thisStore); 40 | }; 41 | 42 | type DerivedMapper = (values: StoresValues) => T; 43 | type SubscribeMapper = ( 44 | values: StoresValues, 45 | set: (value: T) => void 46 | ) => Unsubscriber | void; 47 | 48 | /** 49 | * A Derived store that is considered 'loaded' when all of its parents have loaded (and so on). 50 | * @param stores Any Readable or array of Readables used to generate the value of this store. 51 | * Any Loadable stores need to load before this store is considered loaded. 52 | * @param subscriberMapper A function that sets the value of the store. 53 | * @param initialValue Initial value 54 | * @returns A Loadable store that whose value is derived from the provided parent stores. 55 | * The loaded value of the store will be ready after awaiting the load function of this store. 56 | */ 57 | export function derived( 58 | stores: S, 59 | fn: SubscribeMapper, 60 | initialValue?: T 61 | ): Loadable; 62 | 63 | /** 64 | * A Derived store that is considered 'loaded' when all of its parents have loaded (and so on). 65 | * @param stores Any Readable or array of Readables used to generate the value of this store. 66 | * Any Loadable stores need to load before this store is considered loaded. 67 | * @param mappingFunction A function that maps the values of the parent store to the value of this store. 68 | * @param initialValue Initial value 69 | * @returns A Loadable store that whose value is derived from the provided parent stores. 70 | * The loaded value of the store will be ready after awaiting the load function of this store. 71 | */ 72 | export function derived( 73 | stores: S, 74 | mappingFunction: DerivedMapper, 75 | initialValue?: T 76 | ): Loadable; 77 | 78 | // eslint-disable-next-line func-style 79 | export function derived( 80 | stores: S, 81 | fn: DerivedMapper | SubscribeMapper, 82 | initialValue?: T 83 | ): Loadable { 84 | flagStoreCreated(); 85 | 86 | const thisStore = vanillaDerived(stores, fn as any, initialValue); 87 | const load = () => loadDependencies(thisStore, loadAll, stores); 88 | const reload = anyReloadable(stores) 89 | ? (visitedMap?: VisitedMap) => { 90 | const visitMap = visitedMap ?? new WeakMap(); 91 | const reloadAndTrackVisits = (stores: S) => reloadAll(stores, visitMap); 92 | return loadDependencies(thisStore, reloadAndTrackVisits, stores); 93 | } 94 | : undefined; 95 | 96 | return { 97 | get store() { 98 | return this; 99 | }, 100 | ...thisStore, 101 | load, 102 | ...(reload && { reload }), 103 | }; 104 | } 105 | 106 | /** 107 | * Create a `Writable` store that allows both updating and reading by subscription. 108 | * @param {*=}value initial value 109 | * @param {StartStopNotifier=}start start and stop notifications for subscriptions 110 | */ 111 | export const writable = ( 112 | value?: T, 113 | start?: StartStopNotifier 114 | ): Writable & Loadable => { 115 | flagStoreCreated(); 116 | let hasEverLoaded = false; 117 | 118 | let resolveLoadPromise: (value: T | PromiseLike) => void; 119 | 120 | let loadPromise: Promise = new Promise((resolve) => { 121 | resolveLoadPromise = (value: T | PromiseLike) => { 122 | hasEverLoaded = true; 123 | resolve(value); 124 | }; 125 | }); 126 | 127 | const updateLoadPromise = (value: T) => { 128 | if (value === undefined && !hasEverLoaded) { 129 | // don't resolve until we get a defined value 130 | return; 131 | } 132 | resolveLoadPromise(value); 133 | loadPromise = Promise.resolve(value); 134 | }; 135 | 136 | const startFunction: StartStopNotifier = (set: Subscriber) => { 137 | const customSet = (value: T) => { 138 | set(value); 139 | updateLoadPromise(value); 140 | }; 141 | // intercept the `set` function being passed to the provided start function 142 | // instead provide our own `set` which also updates the load promise. 143 | return start(customSet, null); 144 | }; 145 | 146 | const thisStore = vanillaWritable(value, start && startFunction); 147 | 148 | const load = async () => { 149 | // Create a dummy subscription when we load the store. 150 | // This ensures that we will have at least one subscriber when 151 | // loading the store so that our start function will run. 152 | const dummyUnsubscribe = thisStore.subscribe(() => { 153 | /* no-op */ 154 | }); 155 | let loadedValue: T; 156 | try { 157 | loadedValue = await loadPromise; 158 | } catch (error) { 159 | dummyUnsubscribe(); 160 | throw error; 161 | } 162 | dummyUnsubscribe(); 163 | return loadedValue; 164 | }; 165 | 166 | if (value !== undefined) { 167 | // immeadietly load stores that are given an initial value 168 | updateLoadPromise(value); 169 | } 170 | 171 | const set = (value: T) => { 172 | thisStore.set(value); 173 | updateLoadPromise(value); 174 | }; 175 | 176 | const update = (updater: Updater) => { 177 | const newValue = updater(get(thisStore)); 178 | thisStore.set(newValue); 179 | updateLoadPromise(newValue); 180 | }; 181 | 182 | return { 183 | get store() { 184 | return this; 185 | }, 186 | ...thisStore, 187 | set, 188 | update, 189 | load, 190 | }; 191 | }; 192 | 193 | /** 194 | * Creates a `Readable` store that allows reading by subscription. 195 | * @param value initial value 196 | * @param {StartStopNotifier}start start and stop notifications for subscriptions 197 | */ 198 | export const readable = ( 199 | value?: T, 200 | start?: StartStopNotifier 201 | ): Loadable => { 202 | const { subscribe, load } = writable(value, start); 203 | 204 | return { 205 | subscribe, 206 | load, 207 | get store() { 208 | return this; 209 | }, 210 | }; 211 | }; 212 | -------------------------------------------------------------------------------- /test/standard-stores/index.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store'; 2 | import { 3 | asyncReadable, 4 | Loadable, 5 | derived, 6 | readable, 7 | writable, 8 | } from '../../src'; 9 | 10 | describe('synchronous derived', () => { 11 | const nonAsyncParent = writable('writable'); 12 | const asyncReadableParent = asyncReadable(undefined, () => 13 | Promise.resolve('loadable') 14 | ); 15 | let reloadableGrandparent: Loadable; 16 | let derivedParent: Loadable; 17 | let mockReload = vi.fn(); 18 | 19 | beforeEach(() => { 20 | mockReload = vi 21 | .fn() 22 | .mockReturnValue('default') 23 | .mockResolvedValueOnce('first value') 24 | .mockResolvedValueOnce('second value') 25 | .mockResolvedValueOnce('third value'); 26 | reloadableGrandparent = asyncReadable(undefined, mockReload, { 27 | reloadable: true, 28 | }); 29 | derivedParent = derived(reloadableGrandparent, ($reloadableGrandparent) => 30 | $reloadableGrandparent?.toUpperCase() 31 | ); 32 | }); 33 | 34 | afterEach(() => { 35 | nonAsyncParent.set('writable'); 36 | mockReload.mockReset(); 37 | }); 38 | 39 | describe('derived', () => { 40 | it('gets derived values after loading and reloading', async () => { 41 | const myDerived = derived( 42 | [nonAsyncParent, asyncReadableParent, derivedParent], 43 | ([$nonAsyncParent, $loadableParent, $derivedParent]) => 44 | `derived from ${$nonAsyncParent}, ${$loadableParent}, ${$derivedParent}` 45 | ); 46 | myDerived.subscribe(vi.fn()); 47 | 48 | expect(myDerived.load()).resolves.toBe( 49 | 'derived from writable, loadable, FIRST VALUE' 50 | ); 51 | await myDerived.load(); 52 | expect(get(myDerived)).toBe( 53 | 'derived from writable, loadable, FIRST VALUE' 54 | ); 55 | await myDerived.reload(); 56 | expect(get(myDerived)).toBe( 57 | 'derived from writable, loadable, SECOND VALUE' 58 | ); 59 | }); 60 | 61 | it('deterministically sets final value when received many updates', () => { 62 | const myDerived = derived( 63 | nonAsyncParent, 64 | ($nonAsyncParent) => $nonAsyncParent 65 | ); 66 | myDerived.subscribe(vi.fn()); 67 | 68 | nonAsyncParent.set('A'); 69 | nonAsyncParent.set('B'); 70 | nonAsyncParent.set('C'); 71 | nonAsyncParent.set('D'); 72 | nonAsyncParent.set('E'); 73 | nonAsyncParent.set('F'); 74 | nonAsyncParent.set('G'); 75 | nonAsyncParent.set('H'); 76 | nonAsyncParent.set('I'); 77 | nonAsyncParent.set('J'); 78 | nonAsyncParent.set('K'); 79 | nonAsyncParent.set('L'); 80 | expect(get(myDerived)).toBe('L'); 81 | }); 82 | 83 | it('subscribes when loading', async () => { 84 | const myWritable = writable('initial'); 85 | const myDerived = derived( 86 | myWritable, 87 | ($myWritable) => `derived from ${$myWritable}` 88 | ); 89 | 90 | let $myDerived = await myDerived.load(); 91 | 92 | expect($myDerived).toBe('derived from initial'); 93 | 94 | myWritable.set('updated'); 95 | 96 | $myDerived = await myDerived.load(); 97 | 98 | expect($myDerived).toBe('derived from updated'); 99 | }); 100 | }); 101 | }); 102 | 103 | describe('readable/writable stores', () => { 104 | describe('writable', () => { 105 | it('only loads after being set', async () => { 106 | let isResolved = false; 107 | const myWritable = writable(); 108 | const resolutionPromise = myWritable 109 | .load() 110 | .then(() => (isResolved = true)); 111 | 112 | expect(get(myWritable)).toBe(undefined); 113 | expect(isResolved).toBe(false); 114 | 115 | myWritable.set('value'); 116 | 117 | await resolutionPromise; 118 | expect(isResolved).toBe(true); 119 | expect(get(myWritable)).toBe('value'); 120 | }); 121 | 122 | it('loads immeadietly when provided initial value', async () => { 123 | const myWritable = writable('initial'); 124 | 125 | const initial = await myWritable.load(); 126 | expect(initial).toBe('initial'); 127 | expect(get(myWritable)).toBe('initial'); 128 | 129 | myWritable.set('updated'); 130 | 131 | const updated = await myWritable.load(); 132 | expect(updated).toBe('updated'); 133 | expect(get(myWritable)).toBe('updated'); 134 | }); 135 | 136 | it('loads to updated value', () => { 137 | const myWritable = writable('foo'); 138 | myWritable.update((value) => `${value}bar`); 139 | 140 | expect(get(myWritable)).toBe('foobar'); 141 | expect(myWritable.load()).resolves.toBe('foobar'); 142 | }); 143 | 144 | it('loads from start function', async () => { 145 | const myWritable = writable(undefined, (set) => { 146 | setTimeout(() => set('value'), 50); 147 | }); 148 | 149 | expect(get(myWritable)).toBe(undefined); 150 | const loaded = await myWritable.load(); 151 | expect(loaded).toBe('value'); 152 | expect(get(myWritable)).toBe('value'); 153 | }); 154 | 155 | it('fires unsubscribe callback when unsubscribed', () => { 156 | const mockStop = vi.fn(); 157 | const myWritable = writable(undefined, (set) => { 158 | set('initial'); 159 | return mockStop; 160 | }); 161 | const unsubscribe = myWritable.subscribe(vi.fn()); 162 | 163 | expect(mockStop).not.toHaveBeenCalled(); 164 | unsubscribe(); 165 | expect(mockStop).toHaveBeenCalled(); 166 | }); 167 | }); 168 | 169 | describe('readable', () => { 170 | it('loads immeadietly when provided initial value', async () => { 171 | const myReadable = readable('initial'); 172 | 173 | const initial = await myReadable.load(); 174 | expect(initial).toBe('initial'); 175 | expect(get(myReadable)).toBe('initial'); 176 | }); 177 | 178 | it('loads from start function', async () => { 179 | const myReadable = readable(undefined, (set) => { 180 | setTimeout(() => set('value'), 50); 181 | }); 182 | 183 | expect(get(myReadable)).toBe(undefined); 184 | const loaded = await myReadable.load(); 185 | expect(loaded).toBe('value'); 186 | expect(get(myReadable)).toBe('value'); 187 | }); 188 | 189 | it('fires unsubscribe callback when unsubscribed', () => { 190 | const mockUnsubscribe = vi.fn(); 191 | const myReadable = readable(undefined, (set) => { 192 | set('initial'); 193 | return mockUnsubscribe; 194 | }); 195 | const unsubscribe = myReadable.subscribe(vi.fn()); 196 | 197 | expect(mockUnsubscribe).not.toHaveBeenCalled(); 198 | unsubscribe(); 199 | expect(mockUnsubscribe).toHaveBeenCalled(); 200 | }); 201 | 202 | it('will load from start function correctly without subscription', async () => { 203 | const myReadable = readable(undefined, (set) => { 204 | setTimeout(() => { 205 | set('value'); 206 | }, 50); 207 | }); 208 | 209 | const $myReadable = await myReadable.load(); 210 | expect($myReadable).toBe('value'); 211 | }); 212 | 213 | it('runs stop callback after loading with no subscriptions', async () => { 214 | const stop = vi.fn(); 215 | 216 | const myReadable = readable(undefined, (set) => { 217 | setTimeout(() => { 218 | set('value'); 219 | }, 50); 220 | return stop; 221 | }); 222 | 223 | const load = myReadable.load(); 224 | expect(stop).not.toHaveBeenCalled(); 225 | const value = await load; 226 | expect(value).toBe('value'); 227 | expect(stop).toHaveBeenCalledTimes(1); 228 | 229 | await myReadable.load(); 230 | await new Promise((resolve) => setTimeout(resolve)); 231 | expect(stop).toHaveBeenCalledTimes(2); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /src/async-stores/index.ts: -------------------------------------------------------------------------------- 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 = ( 51 | stores: S, 52 | mappingLoadFunction: (values: StoresValues) => Promise | T, 53 | mappingWriteFunction?: ( 54 | value: T, 55 | parentValues?: StoresValues, 56 | oldValue?: T 57 | ) => Promise, 58 | options: AsyncStoreOptions = {} 59 | ): WritableLoadable => { 60 | flagStoreCreated(); 61 | const { reloadable, trackState, initial } = options; 62 | 63 | const loadState = trackState 64 | ? writable(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; 74 | 75 | // most recent call of mappingLoadFunction, including resulting side effects 76 | // (updating store value, tracking state, etc) 77 | let currentLoadPromise: Promise; 78 | 79 | const tryLoad = async (values: StoresValues) => { 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>, 94 | forceReload?: boolean 95 | ) => Promise; 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>, 113 | forceReload = false 114 | ) => { 115 | const loadParentStores = parentLoadFunction(stores); 116 | 117 | try { 118 | await loadParentStores; 119 | } catch { 120 | currentLoadPromise = loadParentStores as Promise; 121 | setState('ERROR'); 122 | return currentLoadPromise; 123 | } 124 | 125 | const storeValues = getStoresArray(stores).map((store) => 126 | get(store) 127 | ) as StoresValues; 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, 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, 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 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 = ( 273 | stores: S, 274 | mappingLoadFunction: (values: StoresValues) => Promise, 275 | options?: AsyncStoreOptions 276 | ): Loadable => { 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 = ( 305 | initial: T, 306 | loadFunction: () => Promise, 307 | options?: Omit, 'initial'> 308 | ): Loadable => { 309 | return asyncDerived([], loadFunction, { ...options, initial }); 310 | }; 311 | -------------------------------------------------------------------------------- /test/persisted/index.test.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store'; 2 | import { 3 | readable, 4 | writable, 5 | StorageType, 6 | configurePersistedConsent, 7 | persisted, 8 | asyncReadable, 9 | configureCustomStorageType, 10 | } from '../../src'; 11 | import { 12 | getLocalStorageItem, 13 | setLocalStorageItem, 14 | removeLocalStorageItem, 15 | getSessionStorageItem, 16 | setSessionStorageItem, 17 | removeSessionStorageItem, 18 | getCookie, 19 | setCookie, 20 | removeCookie, 21 | } from '../../src/persisted/storage-utils'; 22 | 23 | describe('persisted', () => { 24 | describe.each([ 25 | [ 26 | 'LOCAL_STORAGE' as StorageType, 27 | getLocalStorageItem, 28 | setLocalStorageItem, 29 | removeLocalStorageItem, 30 | ], 31 | [ 32 | 'SESSION_STORAGE' as StorageType, 33 | getSessionStorageItem, 34 | setSessionStorageItem, 35 | removeSessionStorageItem, 36 | ], 37 | ['COOKIE' as StorageType, getCookie, setCookie, removeCookie], 38 | ])( 39 | 'storage type %s', 40 | (storageType, getStorage, setStorage, removeStorage) => { 41 | afterEach(() => { 42 | removeStorage('key'); 43 | }); 44 | 45 | describe('using initial values', () => { 46 | it('writes default to storage', async () => { 47 | const myStorage = persisted('default', 'key', { storageType }); 48 | 49 | await myStorage.load(); 50 | 51 | expect(getStorage('key')).toBe('default'); 52 | expect(get(myStorage)).toBe('default'); 53 | expect(myStorage.load()).resolves.toBe('default'); 54 | }); 55 | 56 | it('clears value from storage', async () => { 57 | const myStorage = persisted('default', 'key', { storageType }); 58 | 59 | await myStorage.load(); 60 | 61 | expect(getStorage('key')).toBe('default'); 62 | expect(get(myStorage)).toBe('default'); 63 | expect(myStorage.load()).resolves.toBe('default'); 64 | 65 | await myStorage.clear(); 66 | expect(getStorage('key')).toBeNull(); 67 | expect(get(myStorage)).toBe(null); 68 | }); 69 | 70 | it('uses stored value if present', async () => { 71 | setStorage('key', 'already set'); 72 | const myStorage = persisted('default', 'key', { storageType }); 73 | 74 | await myStorage.load(); 75 | 76 | expect(getStorage('key')).toBe('already set'); 77 | expect(get(myStorage)).toBe('already set'); 78 | expect(myStorage.load()).resolves.toBe('already set'); 79 | }); 80 | 81 | it('updates stored value when set', async () => { 82 | setStorage('key', 'already set'); 83 | const myStorage = persisted('default', 'key', { storageType }); 84 | await myStorage.set('new value'); 85 | 86 | expect(getStorage('key')).toBe('new value'); 87 | expect(get(myStorage)).toBe('new value'); 88 | expect(myStorage.load()).resolves.toBe('new value'); 89 | }); 90 | 91 | it('updates stored value when updated', async () => { 92 | setStorage('key', 'already set'); 93 | const myStorage = persisted('default', 'key', { storageType }); 94 | await myStorage.update((oldValue) => `${oldValue} + new value`); 95 | 96 | expect(getStorage('key')).toBe('already set + new value'); 97 | expect(get(myStorage)).toBe('already set + new value'); 98 | expect(myStorage.load()).resolves.toBe('already set + new value'); 99 | }); 100 | 101 | it('does not load until set', async () => { 102 | let isResolved = false; 103 | const myStorage = persisted(undefined, 'key', { storageType }); 104 | const resolutionPromise = myStorage 105 | .load() 106 | .then(() => (isResolved = true)); 107 | 108 | expect(get(myStorage)).toBe(undefined); 109 | expect(isResolved).toBe(false); 110 | expect(getStorage('key')).toBeFalsy(); 111 | 112 | myStorage.set('new value'); 113 | 114 | await resolutionPromise; 115 | expect(isResolved).toBe(true); 116 | expect(getStorage('key')).toBe('new value'); 117 | expect(get(myStorage)).toBe('new value'); 118 | expect(myStorage.load()).resolves.toBe('new value'); 119 | }); 120 | 121 | it('loads using null value', async () => { 122 | const myStorage = persisted(null, 'key', { storageType }); 123 | 124 | await myStorage.load(); 125 | expect(get(myStorage)).toBe(null); 126 | expect(getStorage('key')).toBe(null); 127 | 128 | await myStorage.set('new value'); 129 | 130 | expect(getStorage('key')).toBe('new value'); 131 | expect(get(myStorage)).toBe('new value'); 132 | expect(myStorage.load()).resolves.toBe('new value'); 133 | }); 134 | 135 | it('reloads to default', async () => { 136 | setStorage('key', 'already set'); 137 | const myStorage = persisted('default', 'key', { 138 | storageType, 139 | reloadable: true, 140 | }); 141 | 142 | await myStorage.load(); 143 | 144 | expect(getStorage('key')).toBe('already set'); 145 | expect(get(myStorage)).toBe('already set'); 146 | expect(myStorage.load()).resolves.toBe('already set'); 147 | 148 | await myStorage.reload(); 149 | 150 | expect(getStorage('key')).toBe('default'); 151 | expect(get(myStorage)).toBe('default'); 152 | expect(myStorage.load()).resolves.toBe('default'); 153 | }); 154 | 155 | it('handles = characters', async () => { 156 | setStorage('key', 'a=b'); 157 | const myStorage = persisted('c=d', 'key', { 158 | storageType, 159 | reloadable: true, 160 | }); 161 | 162 | let $storageA = await myStorage.load(); 163 | 164 | expect($storageA).toBe('a=b'); 165 | expect(getStorage('key')).toBe('a=b'); 166 | 167 | $storageA = await myStorage.reload(); 168 | 169 | expect($storageA).toBe('c=d'); 170 | expect(getStorage('key')).toBe('c=d'); 171 | }); 172 | }); 173 | 174 | describe('using Loadable initial', () => { 175 | it('writes default to storage', async () => { 176 | const myStorage = persisted(readable('default'), 'key', { 177 | storageType, 178 | }); 179 | 180 | await myStorage.load(); 181 | 182 | expect(getStorage('key')).toBe('default'); 183 | expect(get(myStorage)).toBe('default'); 184 | expect(myStorage.load()).resolves.toBe('default'); 185 | }); 186 | 187 | it('uses stored value if present', async () => { 188 | const mockLoad = vi.fn(); 189 | 190 | setStorage('key', 'already set'); 191 | 192 | const myStorage = persisted( 193 | asyncReadable(undefined, mockLoad), 194 | 'key', 195 | { 196 | storageType, 197 | } 198 | ); 199 | 200 | await myStorage.load(); 201 | 202 | expect(getStorage('key')).toBe('already set'); 203 | expect(get(myStorage)).toBe('already set'); 204 | expect(myStorage.load()).resolves.toBe('already set'); 205 | expect(mockLoad).not.toHaveBeenCalled(); 206 | }); 207 | 208 | it('does not load until default loads', async () => { 209 | let isResolved = false; 210 | const myDefault = writable(); 211 | const myStorage = persisted(myDefault, 'key', { storageType }); 212 | const resolutionPromise = myStorage 213 | .load() 214 | .then(() => (isResolved = true)); 215 | 216 | expect(get(myStorage)).toBe(undefined); 217 | expect(isResolved).toBe(false); 218 | expect(getStorage('key')).toBeFalsy(); 219 | 220 | myDefault.set('new value'); 221 | 222 | await resolutionPromise; 223 | expect(isResolved).toBe(true); 224 | expect(getStorage('key')).toBe('new value'); 225 | expect(get(myStorage)).toBe('new value'); 226 | expect(myStorage.load()).resolves.toBe('new value'); 227 | }); 228 | 229 | it('reloads to default', async () => { 230 | setStorage('key', 'already set'); 231 | const myStorage = persisted(readable('default'), 'key', { 232 | storageType, 233 | reloadable: true, 234 | }); 235 | 236 | await myStorage.load(); 237 | 238 | expect(getStorage('key')).toBe('already set'); 239 | expect(get(myStorage)).toBe('already set'); 240 | expect(myStorage.load()).resolves.toBe('already set'); 241 | 242 | await myStorage.reload(); 243 | 244 | expect(getStorage('key')).toBe('default'); 245 | expect(get(myStorage)).toBe('default'); 246 | expect(myStorage.load()).resolves.toBe('default'); 247 | }); 248 | 249 | it('reloads reloadable default', async () => { 250 | const mockLoad = vi 251 | .fn() 252 | .mockResolvedValueOnce('first value') 253 | .mockResolvedValueOnce('second value'); 254 | 255 | const myStorage = persisted( 256 | asyncReadable(undefined, mockLoad, { reloadable: true }), 257 | 'key', 258 | { 259 | storageType, 260 | reloadable: true, 261 | } 262 | ); 263 | 264 | await myStorage.load(); 265 | 266 | expect(getStorage('key')).toBe('first value'); 267 | expect(get(myStorage)).toBe('first value'); 268 | expect(myStorage.load()).resolves.toBe('first value'); 269 | 270 | await myStorage.reload(); 271 | 272 | expect(getStorage('key')).toBe('second value'); 273 | expect(get(myStorage)).toBe('second value'); 274 | expect(myStorage.load()).resolves.toBe('second value'); 275 | }); 276 | }); 277 | 278 | describe('using async key', () => { 279 | it('writes default to storage', async () => { 280 | const myStorage = persisted('default', () => Promise.resolve('key'), { 281 | storageType, 282 | }); 283 | 284 | await myStorage.load(); 285 | 286 | expect(getStorage('key')).toBe('default'); 287 | expect(get(myStorage)).toBe('default'); 288 | expect(myStorage.load()).resolves.toBe('default'); 289 | }); 290 | 291 | it('uses stored value if present', async () => { 292 | setStorage('key', 'already set'); 293 | const myStorage = persisted('default', () => Promise.resolve('key'), { 294 | storageType, 295 | }); 296 | 297 | await myStorage.load(); 298 | 299 | expect(getStorage('key')).toBe('already set'); 300 | expect(get(myStorage)).toBe('already set'); 301 | expect(myStorage.load()).resolves.toBe('already set'); 302 | }); 303 | 304 | it('updates stored value when set', async () => { 305 | setStorage('key', 'already set'); 306 | const myStorage = persisted('default', () => Promise.resolve('key'), { 307 | storageType, 308 | }); 309 | await myStorage.set('new value'); 310 | 311 | expect(getStorage('key')).toBe('new value'); 312 | expect(get(myStorage)).toBe('new value'); 313 | expect(myStorage.load()).resolves.toBe('new value'); 314 | }); 315 | 316 | it('updates stored value when updated', async () => { 317 | setStorage('key', 'already set'); 318 | const myStorage = persisted('default', () => Promise.resolve('key'), { 319 | storageType, 320 | }); 321 | await myStorage.update((oldValue) => `${oldValue} + new value`); 322 | 323 | expect(getStorage('key')).toBe('already set + new value'); 324 | expect(get(myStorage)).toBe('already set + new value'); 325 | expect(myStorage.load()).resolves.toBe('already set + new value'); 326 | }); 327 | 328 | it('does not load until set', async () => { 329 | let isResolved = false; 330 | const myStorage = persisted(undefined, () => Promise.resolve('key'), { 331 | storageType, 332 | }); 333 | const resolutionPromise = myStorage 334 | .load() 335 | .then(() => (isResolved = true)); 336 | 337 | expect(get(myStorage)).toBe(undefined); 338 | expect(isResolved).toBe(false); 339 | expect(getStorage('key')).toBeFalsy(); 340 | 341 | myStorage.set('new value'); 342 | 343 | await resolutionPromise; 344 | expect(isResolved).toBe(true); 345 | expect(getStorage('key')).toBe('new value'); 346 | expect(get(myStorage)).toBe('new value'); 347 | expect(myStorage.load()).resolves.toBe('new value'); 348 | }); 349 | 350 | it('reloads to default', async () => { 351 | setStorage('key', 'already set'); 352 | const myStorage = persisted('default', () => Promise.resolve('key'), { 353 | storageType, 354 | reloadable: true, 355 | }); 356 | 357 | await myStorage.load(); 358 | 359 | expect(getStorage('key')).toBe('already set'); 360 | expect(get(myStorage)).toBe('already set'); 361 | expect(myStorage.load()).resolves.toBe('already set'); 362 | 363 | await myStorage.reload(); 364 | 365 | expect(getStorage('key')).toBe('default'); 366 | expect(get(myStorage)).toBe('default'); 367 | expect(myStorage.load()).resolves.toBe('default'); 368 | }); 369 | }); 370 | 371 | describe('consent configuration', () => { 372 | afterEach(() => { 373 | configurePersistedConsent(undefined); 374 | }); 375 | 376 | it('persists data if consent check passes', async () => { 377 | configurePersistedConsent( 378 | (consentLevel) => consentLevel === 'CONSENT' 379 | ); 380 | const myStorage = persisted('default', 'key', { 381 | storageType, 382 | consentLevel: 'CONSENT', 383 | }); 384 | 385 | await myStorage.load(); 386 | 387 | expect(getStorage('key')).toBe('default'); 388 | expect(get(myStorage)).toBe('default'); 389 | expect(myStorage.load()).resolves.toBe('default'); 390 | 391 | await myStorage.set('updated'); 392 | 393 | expect(getStorage('key')).toBe('updated'); 394 | expect(get(myStorage)).toBe('updated'); 395 | expect(myStorage.load()).resolves.toBe('updated'); 396 | }); 397 | 398 | it('does not persist data if consent check fails', async () => { 399 | configurePersistedConsent( 400 | (consentLevel) => consentLevel === 'CONSENT' 401 | ); 402 | const myStorage = persisted('default', 'key', { 403 | storageType, 404 | consentLevel: 'NO_CONSENT', 405 | }); 406 | 407 | await myStorage.load(); 408 | 409 | expect(getStorage('key')).toBeNull(); 410 | expect(get(myStorage)).toBe('default'); 411 | expect(myStorage.load()).resolves.toBe('default'); 412 | 413 | await myStorage.set('updated'); 414 | 415 | expect(getStorage('key')).toBe(null); 416 | expect(get(myStorage)).toBe('updated'); 417 | expect(myStorage.load()).resolves.toBe('updated'); 418 | }); 419 | 420 | it('does not persist data if no consent level given', async () => { 421 | configurePersistedConsent( 422 | (consentLevel) => consentLevel === 'CONSENT' 423 | ); 424 | const myStorage = persisted('default', 'key', { 425 | storageType, 426 | }); 427 | 428 | await myStorage.load(); 429 | 430 | expect(getStorage('key')).toBeNull(); 431 | expect(get(myStorage)).toBe('default'); 432 | expect(myStorage.load()).resolves.toBe('default'); 433 | 434 | await myStorage.set('updated'); 435 | 436 | expect(getStorage('key')).toBe(null); 437 | expect(get(myStorage)).toBe('updated'); 438 | expect(myStorage.load()).resolves.toBe('updated'); 439 | }); 440 | }); 441 | } 442 | ); 443 | describe('custom storage type', () => { 444 | it('allows use of custom storage type', async () => { 445 | const customStorage = {}; 446 | 447 | configureCustomStorageType('CUSTOM', { 448 | getStorageItem: (key) => customStorage[key], 449 | setStorageItem: (key, value) => { 450 | customStorage[key] = value; 451 | }, 452 | removeStorageItem: (key) => { 453 | delete customStorage[key]; 454 | }, 455 | }); 456 | 457 | const customStore = persisted('default', 'customKey', { 458 | storageType: 'CUSTOM', 459 | }); 460 | 461 | const result = await customStore.load(); 462 | expect(result).toBe('default'); 463 | expect(customStorage['customKey']).toBe('default'); 464 | 465 | await customStore.set('updated'); 466 | expect(customStorage['customKey']).toBe('updated'); 467 | 468 | await customStore.clear(); 469 | expect(customStorage['customKey']).toBeUndefined(); 470 | }); 471 | }); 472 | }); 473 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Square Svelte Store 2 | 3 | Extension of svelte default stores for dead-simple handling of complex asynchronous behavior. 4 | 5 | ## What it does 6 | 7 | Square Svelte Store builds upon Svelte's default store behavior to empower your app to reactively respond to asynchronous data. Familiar syntax lets you build out async stores as easily as the ones you are already using, with full compatibility between them. Behind-the-scenes smarts handle order of operations, lazy loading, and limiting network calls, allowing you to focus on the relationships between data. 8 | 9 | *A preview...* 10 | 11 | ```javascript 12 | // You can declare an asyncDerived store just like a derived store, 13 | // but with an async function to set the store's value! 14 | const searchResults = asyncDerived( 15 | [authToken, searchTerms], 16 | async ([$authToken, $searchTerms]) => { 17 | const rawResults = await search($authToken, $searchTerms); 18 | return formatResults(rawResults); 19 | } 20 | ); 21 | ``` 22 | 23 | ## The Basics 24 | 25 | Square Svelte Store is intended as a replacement for importing from `svelte/store`. It includes all of the features of `svelte/store` while also adding new stores and extending functionality for compatibility between them. 26 | 27 | ### Loadable 28 | 29 | Stores exported by @square/svelte-store are a new type: `Loadable`. Loadable stores work the same as regular stores--you can derive from them, subscribe to them, and access their value reactively in a component by using the `$` accessor. But they also include extra functionality: a `load` function is available on every store. This function is asynchronous, and resolves to the value of the store after it has finished its async behavior. This lets you control the display of your app based on the status of async routines while also maintaining reactivity! 30 | 31 | ```javascript 32 | {#await myLoadableStore.load()} 33 |

Currently loading...

34 | {:then} 35 |

Your loaded data is: {$myLoadableStore}

36 | {/await} 37 | ``` 38 | 39 | What's better is that any derived store loads all of its parents before loading itself, allowing you to `await`loading of the derived store to automatically wait for all required data dependencies. This means that *no matter how complex* the relationships between your async and synchronous data gets you will *always* be able to ensure that a given store has its final value simply by awaiting `.load()`! 40 | 41 | ### Reloadable 42 | 43 | While hydrating your app with data, some endpoints you will only need to access once. Others you will need to access multiple times. By default async stores will only load once unless a store they derive from changes. However if you would like an async store to be able to load new data you can declare it to be `reloadable` during creation. If you do so, the store, and any stores that ultimately derive from it, will have access to a `reload` function. Calling the reload function of a Reloadable store will cause it fetch new data, and calling the reload function of any store that derives from a Reloadable store will cause that Reloadable store to reload. In this manner you can call reload on a store and it will reload any sources of data that should be refreshed without unnecessarily creating promises for data that should not be refreshed. 44 | 45 | ## The New Stores 46 | 47 | ### asyncReadable 48 | 49 | An asyncReadable store provides easy asynchronous support to readable stores. Like a readable store, an asyncReadable store takes in an initial value and a function that is called when the store is first subscribed to. For an asyncReadable store this function is an async `loadFunction` which takes no arguments and returns the loaded value of the store. An optional third parameter can specify options for the store, in this case declaring it to be reloadable. 50 | 51 | *asyncReadable stores are super simple! Let's see it in action...* 52 | 53 | ```javascript 54 | const userInfo = asyncReadable( 55 | {}, 56 | async () => { 57 | const response = await fetch('https://ourdomain.com/users/info'); 58 | const userObject = await response.json(); 59 | return userObject; 60 | }, 61 | { reloadable: true } 62 | ); 63 | ``` 64 | 65 | Now we have a Loadable and reloadable userInfo store! As soon as our app renders a component that needs data from userInfo it will begin to load. We can `{#await userInfo.load()}` in our components that need userInfo. This will delay rendering until we have the data we need. Since we have declared the store to be reloadable we can call `userInfo.reload()` to pull new data (and reactively update our components once we have it). 66 | 67 | ### derived 68 | 69 | Okay this isn't a new store, but it does have some new features! We declare a derived store the same as ever, but it now gives us access to a `load` function. This load function resolves after all parents have loaded and the derived store has calculated its final value. 70 | 71 | *What does that mean for our app..?* 72 | 73 | ```javascript 74 | const userSettings = derived(userInfo, ($userInfo) => $userInfo?.settings); 75 | const darkMode = derived(userSettings, ($userSetting) => $userSettings?.darkMode); 76 | ``` 77 | 78 | Now we've got a darkMode store that tracks whether our user has selected darkMode for our app. When we use this store in a component we can call `darkMode.load()`. This awaits userSettings loading, which in turn awaits userInfo. In this way, we can load a derived store to automatically load the sources of its data and to wait for its final value. What's more, since darkMode derives from a reloadable source, we can call `darkMode.reload()` to get new userInfo if we encounter a situation where the user's darkMode setting may have changed. 79 | 80 | This isn't very impressive with our simple example, but as we build out our app and encounter situations where derived values come fom multiple endpoints through several layers of derivations this becomes much more useful. Being able to call load and reload on just the data you need is much more convenient than tracking down all of the dependencies involved! 81 | 82 | ### asyncDerived 83 | 84 | An asyncDerived store works just like a derived store, but with an asynchronous call to get the final value of the store! 85 | 86 | *Let's jump right in...* 87 | 88 | ```javascript 89 | const results = asyncDerived( 90 | [authToken, page], 91 | async ([$authToken, $page]) => { 92 | const requestBody = JSON.stringify({ authorization: $authToken }); 93 | const response = await fetch( 94 | `https://ourdomain.com/list?page=${$page}`, 95 | requestBody 96 | ); 97 | return response.json(); 98 | } 99 | ); 100 | ``` 101 | 102 | Here we have a store that reflects a paginated set of results from an endpoint. Just like a regular derived store we include a function that maps the values of parent stores to the value of this store. Of course with an async store we use an async function. However, while regular derived stores will invoke that function whenever any of the parent values changes (including initialization) an asyncDerived store will only do so after all of the parents have finished loading. This means you don't need to worry about creating unnecessary or premature network calls. 103 | 104 | After the stores have finished loading any new changes to the parent stores will create a new network request. In this example if we write to the page store when the user changes pages we will automatically make a new request that will update our results store. Just like with asyncReadable stores we can include a boolean to indicate that an asyncDerived store will be Reloadable. 105 | 106 | ### asyncWritable 107 | 108 | Here's where things get a little more complicated. Just like the other async stores this store mirrors an existing store. Like a regular writable store this store will have `set` and `update` functions that lets you set the store's value. But why would we want to set the value of the store if the store's value comes from a network call? To answer this let's consider the following use case: in our app we have a list of favorite shortcuts for our user. They can rearrange these shortcuts in order to personalize their experience. When a user rearranges their shortcuts we could manually make a new network request to save their choice, then reload the async store that tracks the list of shortcuts. However that would mean that the user would not see the results of their customization until the network request completes. Instead we can use an asyncWritable store. When the user customizes their list of shortcuts we will optimistically update the corresponding store. This update kicks off a network request to save the user's customization to our backend. Finally, when the network request completes we update our store to reflect the canonical version of the user's list. 109 | 110 | *So how do we accomplish this using an asyncWritable store..?* 111 | 112 | ```javascript 113 | const shortcuts = asyncWritable( 114 | [], 115 | async () => { 116 | const response = await fetch('https://ourdomain.com/shortcuts'); 117 | return response.json(); 118 | }, 119 | async (newShortcutsList) => { 120 | const postBody = JSON.stringify({ shortcuts: newShortcutsList }); 121 | const response = await fetch('https://ourdomain.com/shortcuts', { 122 | method: 'POST', 123 | body: postBody, 124 | }); 125 | return response.json(); 126 | } 127 | ); 128 | ``` 129 | 130 | Our first two arguments work just like an asyncDerived store--we can pass any number of stores and we can use their values to set the value of the store once the parents have loaded. If we don't need to derive from any store we can pass `[]` as our first argument. For our third argument we optionally provide a write function that is invoked when we `set` or `update` the value of the store ourself. It takes in the new value of the store and then performs the work to persist that to the backend. If we invoke `shortcuts.set()` first the store updates to the value we pass to the function. Then it invokes the async function we provided during definition in order to persist the new data. Finally it sets the value of the store to what we return from the async function. If our endpoint does not return any useful data we can instead have our async function return void and skip this step. 131 | 132 | One final feature is that we can include a second argument for our write function that will receive the values of parent stores. 133 | 134 | *Let's look at what that looks like...* 135 | 136 | ```javascript 137 | const shortcuts = asyncWritable( 138 | authToken, 139 | async ($authToken) => { 140 | const requestBody = JSON.stringify({ authorization: $authToken }); 141 | const response = await fetch( 142 | 'https://ourdomain.com/shortcuts', 143 | requestBody 144 | ); 145 | return response.json(); 146 | }, 147 | async (newShortcutsList, $authToken) => { 148 | const postBody = JSON.stringify({ 149 | authorization: $authToken, 150 | shortcuts: newShortcutsList, 151 | }); 152 | const response = await fetch('https://ourdomain.com/shortcuts', { 153 | method: 'POST', 154 | body: postBody, 155 | }); 156 | return response.json(); 157 | } 158 | ); 159 | ``` 160 | 161 | In this example we derive from an authToken store and include it in both our GET and POST requests. 162 | 163 | Some niche features of asyncWritable stores allow for more specific error handling of write functions. The write function we provide as the third argument can be written to accept a third argument that receives the value of the store before it was set. This allows for resetting the value of the store in the case of a write failure by catching the error and returning the old value. A similar feature is that both the `set` and `update` functions can take a second argument that indicates whether the async write functionality should be called during the set process. 164 | 165 | ### readable/writable 166 | 167 | Similarly to derived stores, addtional load functionality is bundled with readable and writable stores. Both readable and writable stores include a `.load()` function that will resolve when the value of the store is first set. If an initial value is provided when creating the store, this means the store will load immeadietly. However, if a value is not provided (left `undefined`) then the store will only load after it is set to a value. This makes it easy to wait on user input, an event listener, etc. in your application. 168 | 169 | *It's easy to wait for user input...* 170 | 171 | ```javascript 172 | 191 | 192 | 193 | 194 | 195 | {#await needsConsent.load()} 196 |

I will only load after hasConsent has been populated

197 |

{$needsConsent}

198 | {/await} 199 | ``` 200 | 201 | ### persisted 202 | 203 | Sometimes data needs to persist outside the lifecycle of our app. By using persisted stores you can accomplish this while gaining all of the other benefits of Loadable stores. A persisted store synchronizes (stringifiable) store data with a sessionStorage item, localStorage item, or cookie. The persisted store loads to the value of the corresponding storage item, if found, otherwise it will load to the provided initial value and persist that value to storage. Any changes to the store will also be persisted! 204 | 205 | *We can persist a user name across page loads...* 206 | 207 | ```javascript 208 | 212 | 213 | // If we reload the page, this input will still have the same value! 214 | 215 | ``` 216 | 217 | If data isn't already in storage, it may need to be fetched asynchronously. In this case we can pass a Loadable store to our persisted store in place of an initial value. Doing so will load the Loadable store if no storage item is found and then synchronize the persisted store and storage with the loaded value. We can also declare the persisted store to be reloadable, in which case a call to `.reload()` will attempt to reload the parent Loadable store and persist the new data to storage. 218 | 219 | *Persisting remote data is simple...* 220 | 221 | ```javascript 222 | const remoteSessionToken = asyncReadable( 223 | undefined, 224 | async () => { 225 | const session = await generateSession(); 226 | return session.token; 227 | }, 228 | { reloadable: true }, 229 | ); 230 | 231 | const sessionToken = persisted( 232 | remoteSessionToken, 233 | 'SESSION_TOKEN', 234 | { reloadable: true, storageType: 'SESSION_STORAGE' } 235 | ); 236 | ``` 237 | 238 | With this setup we can persist our remote data across a page session! The first page load of the session will load from the remote source, but successive page loads will use the persisted token in session storage. What's more is that because Loadable stores are lazily loaded, `remoteSessionToken` will only fetch remote data when its needed for `sessionToken` (provided there are no other subscribers to `remoteSessionToken`). If our session token ever expires we can force new data to be loaded by calling `sessionToken.reload()`! 239 | 240 | If an external source updates the storage item of the persisted store the two values will go out of sync. In such a case we can call `.resync()` on the store in order to update the store the *parsed* value of the storage item. 241 | 242 | We are also able to wipe stored data by calling `clear()` on the store. The storage item will be removed and the value of the store set to `null`. 243 | 244 | #### persisted configuration / custom storage 245 | 246 | Persisted stores have three built in storage types: LOCAL_STORAGE, SESSION_STORAGE, and COOKIE. These should be sufficient for most use cases, but have the disadvantage of only being able to store JSON serializable data. If more advanced behavior is required we can define a custom storage type to handle this, such as integrating [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). All that is required is for us to provide the relevant setter/getter/deleter functions for interfacing with our storage. 247 | 248 | *One time setup is all that is needed for custom storage...* 249 | 250 | ```javascript 251 | configureCustomStorageType('INDEXED_DB', { 252 | getStorageItem: (key) => /* get from IndexedDB */, 253 | setStorageItem: (key, value) => /* persist to IndexedDB */, 254 | removeStorageItem: (key) => /* delete from IndexedDB */, 255 | }); 256 | 257 | const customStore = persisted('defaultValue', 'indexedDbKey', { 258 | storageType: 'INDEXED_DB', 259 | }); 260 | ``` 261 | 262 | #### persisted configuration / consent 263 | 264 | Persisting data to storage or cookies is subject to privacy laws regarding consent in some jurisdictions. Instead of building two different data flows that depend on whether tracking consent has been given or not, you can instead configure your persisted stores to work in both cases. To do so you will need to call the `configurePersistedConsent` function and pass in a consent checker that will accept a `consent level` and return a boolean indicating whether your user has consented to that level of tracking. You can then provide a consent level when building your persisted stores that will be passed to to the checker before storing data. 265 | 266 | *GDPR compliance is easy...* 267 | 268 | ```javascript 269 | configurePersistedConsent( 270 | (consentLevel) => window.consentLevels.includes(consentLevel); 271 | ); 272 | 273 | const hasDismissedTooltip = persisted( 274 | false, 275 | 'TOOLTIP_DISMISSED', 276 | { 277 | storageType: 'COOKIE', 278 | consentLevel: 'TRACKING' 279 | } 280 | ); 281 | ``` 282 | 283 | Here we hypothesize a setup where a user's consentLevels are accessible through the window object. We would like to track the dismissal of a tooltip and ideally persist that across page loads. To do so we set up a `hasDismissedTooltip` store that can bet set like any other writable store. If the user has consented to the `TRACKING` consent level, then setting the store will also set a `TOOLTIP_DISMISSED` cookie. Otherwise no data will be persisted and the store will initialize to the default value `false` on each page load. 284 | 285 | Note that if no consent level is provided, `undefined` will be passed to the consent checker. This can be handled to provide a default consent for your persisted stores when a consent level is not provided. 286 | 287 | ### state 288 | 289 | State stores are a kind of non-Loadable Readable store that can be generated alongside async stores in order to track their load state. This can be done by passing the `trackState` to the store options during creation. This is particular useful for reloadable or asyncDerived stores which might go into a state of pulling new data. 290 | 291 | *State stores can be used to conditionally render our data...* 292 | 293 | ```javascript 294 | 306 | 307 | 308 | 309 | {#if $searchState.isLoading} 310 | 311 | {:else if $searchState.isLoaded} 312 | 313 | {:else if $searchState.isReloading} 314 | 315 | 316 | {:else if $searchState.isError} 317 | 318 | {/if} 319 | 320 | ``` 321 | 322 | We are able to easily track the current activity of our search flow using `trackState`. Our `searchState` will initialize to `LOADING`. When the `searchTerms` store is first set it will `load`, which will kick off `searchTerms` own loading process. After that completes searchState will update to `LOADED`. Any further changes to `searchTerms` will kick off a new load process, at which point `searchTerms` will update to `RELOADING`. We are also able to check summary states: `isPending` is true when `LOADING` or `RELOADING` and `isSettled` is true when `LOADED` or `ERROR`. 323 | 324 | Note that trackState is (currently) only available on asyncStores -- asyncReadable, asyncWritable, and asyncDerived. 325 | 326 | ### asyncClient 327 | 328 | An asyncClient is a special kind of store that expands the functionality of another Loadable store. Creating an asyncClient allows you to start accessing the propeties of the object in your store before it has loaded. This is done by transforming all of the object's properties into asynchronous functions that will resolve when the store has loaded. 329 | 330 | *Confusing in concept, but simple in practice...* 331 | 332 | ```javascript 333 | const logger = asyncClient(readable( 334 | undefined, 335 | (set) => { 336 | addEventListener('LOGGING_READY', () => { 337 | set({ 338 | logError: (error) => window.log('ERROR', error.message), 339 | logMessage: (message) => window.log('INFO', message), 340 | }); 341 | }) 342 | } 343 | )); 344 | 345 | logger.logMessage('Logging ready'); 346 | ``` 347 | 348 | In this example we assume a hypothetical flow where a `LOGGING_READY` event is fired upon an external library adding a generic logger to the window object. We create a readable store that loads when this event fires, and set up an object with two functions for logging either errors or non-error messages. If we did not use an asyncClient we would need to call logMessage like so: 349 | `logger.load().then(($logger) => $logger.logMessage('Logging ready'))` 350 | However, by turning the readable store into an asyncClient we can instead call `logger.logMessage` immeadietly and the message will be logged when the `LOGGING_READY` event fires. 351 | 352 | Note that the asyncClient is still a store, and so can perform all of the store functionality of what it wraps. This means, for example, that you can make an asyncClient of a writable store and have access to the `set` and `update` functions. 353 | 354 | Non-function properties of the object loaded by the asyncClient can also be accessed using an async function. I.e. if an asyncClient loads to `{foo: 'bar'}`, `myClient.foo()` will resolve to 'bar' when the asyncClient has loaded. 355 | The property access for an asyncClient is performed dynamically, and that means that *any* property can attempt to be accessed. If the property can not be found when the asyncClient loads, this will resolve to `undefined`. It is recommended to use typescript to ensure that the accessed properties are members of the store's type. 356 | 357 | If a store loads directly to a function, an asyncClient can be used to asynchronously invoke that function. 358 | 359 | *We can call loaded functions easily...* 360 | 361 | ```javascript 362 | const logMessage = asyncClient(readable( 363 | undefined, 364 | (set) => { 365 | addEventListener('LOGGING_READY', () => { 366 | set((message) => window.log('INFO', message)); 367 | }) 368 | } 369 | )); 370 | 371 | logMessage('Logging ready') 372 | ``` 373 | 374 | Instead of defining a store that holds an object with function properties, we instead have the store hold a function directly. As before, `logMessage` will be called when the `LOGGING_READY` event fires and the store loads. 375 | 376 | ## Additional functions 377 | 378 | ### isLoadable and isReloadable 379 | 380 | The isLoadable and isReloadable functions let you check if a store is Loadable or Reloadable at runtime. 381 | 382 | ### loadAll 383 | 384 | The loadAll function can take in an array of stores and returns a promise that will resolve when any loadable stores provided finish loading. This is useful if you have a component that uses multiple stores and want to delay rendering until those stores have populated. 385 | 386 | ### safeLoad 387 | 388 | The safeLoad function works similarly to loadAll, however any loading errors of the given stores will be caught, and a boolean returned representing whether loading all of the provided stores was performed successfully or not. This can be useful when you wish to handle possible loading errors, yet still want to render content upon failure. 389 | 390 | ```javascript 391 | {#await safeLoad(myStore) then loadedSuccessfully} 392 | {#if !loadedSuccessfully} 393 | 394 | {/if} 395 | 396 | {/await} 397 | ``` 398 | 399 | ### logAsyncErrors 400 | 401 | Using safeLoad or `{#await}{:then}{:catch}` blocks in templates allows you to catch and handle errors that occur during our async stores loading. However this can lead to a visibility problem: if you always catch the errors you may not be aware that your users are experiencing them. To deal with this you can pass an error logger into the `logAsyncErrors` function before you set up your stores. Then any time one of our async stores experiences an error while loading it will automatically call your error logging function regardless of how you handle the error downstream. 402 | 403 | ### aborting / rebounce (BETA) 404 | 405 | Async functionality based on user input is prone to subtle race conditions and async stores are no exception. For example, imagine you want to get a paginated list of items. When the user changes pages a new request is made and then assigned to a `currentItems` variable. If a user changes pages quickly the requests to the items endpoint may resolve in a different order than they were made. If this happens, `currentItems` will reflect the last request to resolve, instead of the last request to be made. Thus the user will see an incorrect page of items. 406 | 407 | The solution for this problem is to `abort` old requests and to only resolve the most recent one. This can be performed on fetch requests using [abort controllers](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). To support this pattern, async stores have special handling for rejections of aborted requests. If a store encounters an abort rejection while loading, the store's value will not update, and the rejection will be caught. 408 | 409 | While fetch requests have built in abort controller support, async functions do not. Thus the `rebounce` function is provided. It can be used to wrap an async function and automatically abort any in-flight calls when a new call is made. 410 | 411 | *Use rebounce to abort stale async calls...* 412 | 413 | ```javascript 414 | let currentItems; 415 | 416 | const getItems = async (page) => { 417 | const results = await itemsRequest(page) 418 | return results.items; 419 | } 420 | 421 | const rebouncedGetItems = rebounce(getItems) 422 | 423 | const changePage = async (page) => { 424 | currentItems = await rebouncedGetItems(page); 425 | } 426 | 427 | changePage(1); 428 | changePage(2); 429 | changePage(3); 430 | ``` 431 | 432 | Without using `rebounce`, `currentItems` would end up equaling the value of `getItems` that *resolves* last. However, when we called the rebounced `getItems` it will equal value of `getItems` that is *called* last. This is because a rebounced function returns a promise that resolves to the returned value of the function, but this promise is aborted when another call to the function is made. This means that when we call `changePage` three times, the first and second calls to `rebouncedGetItems` will reject and only the third call will update `currentItems`. 433 | 434 | *Using rebounce with stores is straight forward...* 435 | 436 | ```javascript 437 | const rebouncedGetItems = rebounce( 438 | async (page) => { 439 | const results = await itemsRequest(page) 440 | return results.items; 441 | }, 442 | 200 443 | ); 444 | 445 | const currentItems = asyncDerived(page, ($page) => { 446 | return rebouncedGetItems($page); 447 | }); 448 | ``` 449 | 450 | Here we have created a store for our `currentItems`. Whenever we update the `page` store we will automatically get our new items. By using `rebounce`, `currentItems` will always reflect the most up-to-date `page` value. Note we have also provided a number when calling rebounce. This creates a corresponding millisecond delay before the rebounced function is called. Successive calls within that time frame will abort the previous calls *before* the rebounced function is invoked. This is useful for limiting network requests. In this example, if our user continues to change the page, an `itemsRequest` will not be made until 200 ms has passed since `page` was updated. This means, if our user rapidly clicks through pages 1 to 10, a network request will only be made (and our `currentItems` store updated) when they have settled on page 10. 451 | 452 | NOTES: 453 | 454 | - In flight rebounces can be manually aborted using `clear`. `rebouncedFunction.clear()` 455 | - Aborting an async function will cause it to reject, but it will not undo any side effects that have been created! Therefore it is critical that side effects are avoided within the rebounced function. Instead they should instead be performed after the rebounce has resolved. 456 | 457 | ## Putting it all Together 458 | 459 | The usefulness of async stores becomes more obvious when dealing with complex relationships between different pieces of async data. 460 | 461 | Let's consider an example scenario that will put our @square/svelte-stores to work. 462 | We are developing a social media website that lets users share and view blogs. In a sidebar we have a list of shortcuts to the users favorite blogs with along with a blurb from their most recent post. We would like to test a feature with 5% of users where we also provide a few suggested blogs alongside their favorites. As the user views new blogs, their suggested list of blogs also updates based on their indicated interests. To support this we have a number of endpoints. 463 | 464 | - A `personalization` endpoint provides a list of the user's favorite and suggested blogs. 465 | - A `preview` endpoint lets us fetch a blurb for the most recent post of a given blog. 466 | - A `favorites` endpoint lets us POST updates a user makes to their favorites. 467 | - A `testing` endpoint lets us determine if the user should be included in the feature test. 468 | - A `user` endpoint lets us gather user info, including a token for identifying the user when calling other endpoints. 469 | 470 | We've got some challenges here. We need the user's ID before we take any other step. We need to query the testing endpoint before we will know whether to display suggestions alongside favorites. And whenever a users shortcuts update we'll need to update our preview blurbs to match. 471 | 472 | Without async stores this could get messy! However by approaching this using stores all we need to worry about is one piece of data at a time, and the pieces we need to get it. 473 | 474 | *[Let's look at an interactive implementation...](https://codesandbox.io/s/square-svelte-store-demo-tbvonh?file=/App.svelte)* 475 | 476 | ## Extras 477 | 478 | If you are using eslint, `eslint-plugin-square-svelte-store` will enforce usage of square-svelte-store and can be used to autofix usages of `svelte/store`. 479 | 480 | ```javascript 481 | // .eslintrc.js 482 | module.exports = { 483 | plugins: ['square-svelte-store'], 484 | rules: {'square-svelte-store/use-square-svelte-stores': 'error'} 485 | } 486 | ``` 487 | 488 | ## Testing 489 | 490 | Testing mode can be enabled using the `enableStoreTestingMode` function before running your tests. If testing mode is enabled async stores will include an additional function, `reset`. This function can be called in between tests in order to force stores to reset their load state and return to their initial value. This is useful to test different load conditions for your app, such as endpoint failures. 491 | -------------------------------------------------------------------------------- /test/async-stores/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | get, 3 | asyncDerived, 4 | asyncReadable, 5 | asyncWritable, 6 | logAsyncErrors, 7 | WritableLoadable, 8 | persisted, 9 | derived, 10 | readable, 11 | writable, 12 | isReloadable, 13 | rebounce, 14 | safeLoad, 15 | } from '../../src'; 16 | 17 | describe('asyncWritable', () => { 18 | const writableParent = writable('writable'); 19 | let mockReload = vi.fn(); 20 | 21 | beforeEach(() => { 22 | mockReload = vi 23 | .fn() 24 | .mockReturnValue('default') 25 | .mockResolvedValueOnce('first value') 26 | .mockResolvedValueOnce('second value') 27 | .mockResolvedValueOnce('third value'); 28 | }); 29 | 30 | afterEach(() => { 31 | writableParent.set('writable'); 32 | mockReload.mockReset(); 33 | }); 34 | 35 | describe('no parents / asyncReadable', () => { 36 | it('loads expected value', async () => { 37 | const myAsyncReadable = asyncReadable(undefined, () => 38 | Promise.resolve('expected') 39 | ); 40 | myAsyncReadable.subscribe(vi.fn()); 41 | 42 | expect(myAsyncReadable.load()).resolves.toBe('expected'); 43 | await myAsyncReadable.load(); 44 | expect(get(myAsyncReadable)).toBe('expected'); 45 | }); 46 | 47 | it('loads initial value when rejected', async () => { 48 | const myAsyncReadable = asyncReadable('initial', () => 49 | Promise.reject(new Error('error')) 50 | ); 51 | const isInitial = derived( 52 | myAsyncReadable, 53 | ($myAsyncReadable) => $myAsyncReadable === 'initial' 54 | ); 55 | expect(get(isInitial)).toBe(true); 56 | 57 | expect(myAsyncReadable.load()).rejects.toStrictEqual(new Error('error')); 58 | await myAsyncReadable.load().catch(() => Promise.resolve()); 59 | expect(get(myAsyncReadable)).toBe('initial'); 60 | expect(get(isInitial)).toBe(true); 61 | }); 62 | 63 | it('does not reload if not reloadable', () => { 64 | const myAsyncDerived = asyncReadable(undefined, mockReload); 65 | myAsyncDerived.subscribe(vi.fn()); 66 | 67 | expect(myAsyncDerived.load()).resolves.toBe('first value'); 68 | expect(isReloadable(myAsyncDerived)).toBeFalsy(); 69 | }); 70 | 71 | it('does reload if reloadable', async () => { 72 | const myAsyncDerived = asyncReadable(undefined, mockReload, { 73 | reloadable: true, 74 | }); 75 | myAsyncDerived.subscribe(vi.fn()); 76 | 77 | expect(myAsyncDerived.load()).resolves.toBe('first value'); 78 | await myAsyncDerived.load(); 79 | await myAsyncDerived.reload(); 80 | expect(get(myAsyncDerived)).toBe('second value'); 81 | expect(myAsyncDerived.load()).resolves.toBe('second value'); 82 | }); 83 | }); 84 | 85 | describe('one parent asyncDerived', () => { 86 | it('loads expected value', async () => { 87 | const myAsyncDerived = asyncDerived(writableParent, (storeValue) => 88 | Promise.resolve(`derived from ${storeValue}`) 89 | ); 90 | myAsyncDerived.subscribe(vi.fn()); 91 | 92 | expect(myAsyncDerived.load()).resolves.toBe('derived from writable'); 93 | await myAsyncDerived.load(); 94 | expect(get(myAsyncDerived)).toBe('derived from writable'); 95 | }); 96 | 97 | it('loads initial value when rejected', async () => { 98 | const myAsyncDerived = asyncDerived( 99 | writableParent, 100 | () => Promise.reject(new Error('error')), 101 | { initial: 'initial' } 102 | ); 103 | myAsyncDerived.subscribe(vi.fn()); 104 | 105 | expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); 106 | await myAsyncDerived.load().catch(() => Promise.resolve()); 107 | expect(get(myAsyncDerived)).toBe('initial'); 108 | }); 109 | 110 | it('does not reload if not reloadable', () => { 111 | const myAsyncDerived = asyncDerived(writableParent, mockReload); 112 | myAsyncDerived.subscribe(vi.fn()); 113 | 114 | expect(myAsyncDerived.load()).resolves.toBe('first value'); 115 | expect(isReloadable(myAsyncDerived)).toBeFalsy(); 116 | }); 117 | 118 | it('does reload if reloadable', async () => { 119 | const myAsyncDerived = asyncDerived(writableParent, mockReload, { 120 | reloadable: true, 121 | }); 122 | myAsyncDerived.subscribe(vi.fn()); 123 | 124 | expect(myAsyncDerived.load()).resolves.toBe('first value'); 125 | await myAsyncDerived.load(); 126 | await myAsyncDerived.reload(); 127 | expect(get(myAsyncDerived)).toBe('second value'); 128 | expect(myAsyncDerived.load()).resolves.toBe('second value'); 129 | }); 130 | 131 | it('does reload if parent updates', async () => { 132 | const myAsyncDerived = asyncDerived(writableParent, mockReload); 133 | myAsyncDerived.subscribe(vi.fn()); 134 | 135 | await myAsyncDerived.load(); 136 | expect(get(myAsyncDerived)).toBe('first value'); 137 | writableParent.set('updated'); 138 | await myAsyncDerived.load(); 139 | expect(get(myAsyncDerived)).toBe('second value'); 140 | }); 141 | 142 | it('loads asyncReadable parent', () => { 143 | const asyncReadableParent = asyncReadable(undefined, mockReload); 144 | const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => 145 | Promise.resolve(`derived from ${storeValue}`) 146 | ); 147 | myAsyncDerived.subscribe(vi.fn()); 148 | 149 | expect(myAsyncDerived.load()).resolves.toBe('derived from first value'); 150 | expect(isReloadable(myAsyncDerived)).toBeFalsy(); 151 | }); 152 | 153 | it('reloads reloadable parent', async () => { 154 | const asyncReadableParent = asyncReadable(undefined, mockReload, { 155 | reloadable: true, 156 | }); 157 | const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => 158 | Promise.resolve(`derived from ${storeValue}`) 159 | ); 160 | myAsyncDerived.subscribe(vi.fn()); 161 | 162 | await myAsyncDerived.load(); 163 | expect(get(myAsyncDerived)).toBe('derived from first value'); 164 | await myAsyncDerived.reload(); 165 | expect(get(myAsyncDerived)).toBe('derived from second value'); 166 | expect(myAsyncDerived.load()).resolves.toBe('derived from second value'); 167 | }); 168 | 169 | it('reloads once when children reload', async () => { 170 | const asyncReadableParent = asyncReadable(undefined, mockReload, { 171 | reloadable: true, 172 | }); 173 | const childA = derived(asyncReadableParent, (storeValue) => storeValue); 174 | const childB = derived(asyncReadableParent, (storeValue) => storeValue); 175 | const grandChild = derived( 176 | [childA, childB], 177 | ([$childA, $childB]) => $childA + $childB 178 | ); 179 | 180 | await grandChild.load(); 181 | expect(get(grandChild)).toBe('first valuefirst value'); 182 | await grandChild.reload(); 183 | expect(get(grandChild)).toBe('second valuesecond value'); 184 | expect(mockReload).toHaveBeenCalledTimes(2); 185 | }); 186 | 187 | it('rejects load when parent load fails', () => { 188 | const asyncReadableParent = asyncReadable(undefined, () => 189 | Promise.reject(new Error('error')) 190 | ); 191 | const myAsyncDerived = asyncDerived(asyncReadableParent, (storeValue) => 192 | Promise.resolve(`derived from ${storeValue}`) 193 | ); 194 | myAsyncDerived.subscribe(vi.fn()); 195 | 196 | expect(myAsyncDerived.load()).rejects.toStrictEqual(new Error('error')); 197 | }); 198 | 199 | it('correcly unsubscribes from parents', async () => { 200 | const writableParent = writable('initial'); 201 | const firstDerivedLoad = vi.fn(($parent) => 202 | Promise.resolve(`${$parent} first`) 203 | ); 204 | const firstDerived = asyncDerived(writableParent, firstDerivedLoad); 205 | const secondDerivedLoad = vi.fn(($parent) => 206 | Promise.resolve(`${$parent} second`) 207 | ); 208 | const secondDerived = asyncDerived(writableParent, secondDerivedLoad); 209 | 210 | let firstValue; 211 | const firstUnsubscribe = firstDerived.subscribe( 212 | (value) => (firstValue = value) 213 | ); 214 | let secondValue; 215 | secondDerived.subscribe((value) => (secondValue = value)); 216 | 217 | // this sucks but I can't figure out a better way to wait for the 218 | // subscribe callbacks to get called without generating a new subscription 219 | await new Promise((resolve) => setTimeout(resolve)); 220 | 221 | expect(firstValue).toBe('initial first'); 222 | expect(secondValue).toBe('initial second'); 223 | expect(firstDerivedLoad).toHaveBeenCalledTimes(1); 224 | expect(secondDerivedLoad).toHaveBeenCalledTimes(1); 225 | 226 | firstUnsubscribe(); 227 | writableParent.set('updated'); 228 | 229 | await new Promise((resolve) => setTimeout(resolve)); 230 | 231 | expect(firstValue).toBe('initial first'); 232 | expect(secondValue).toBe('updated second'); 233 | expect(firstDerivedLoad).toHaveBeenCalledTimes(1); 234 | expect(secondDerivedLoad).toHaveBeenCalledTimes(2); 235 | }); 236 | 237 | describe('abort/rebounce integration', () => { 238 | it('loads to rebounced value only', async () => { 239 | const load = (value: string) => { 240 | return new Promise((resolve) => 241 | setTimeout(() => resolve(value), 100) 242 | ); 243 | }; 244 | 245 | const rebouncedLoad = rebounce(load); 246 | const myParent = writable(); 247 | const { store: myStore, state: myState } = asyncDerived( 248 | myParent, 249 | rebouncedLoad, 250 | { 251 | trackState: true, 252 | } 253 | ); 254 | 255 | let setIncorrectly = false; 256 | myStore.subscribe((value) => { 257 | if (['a', 'b'].includes(value)) { 258 | setIncorrectly = true; 259 | } 260 | }); 261 | 262 | let everErrored = false; 263 | myState.subscribe((state) => { 264 | if (state.isError) { 265 | everErrored = true; 266 | } 267 | }); 268 | 269 | myParent.set('a'); 270 | await new Promise((resolve) => setTimeout(resolve, 50)); 271 | expect(get(myState).isLoading).toBe(true); 272 | myParent.set('b'); 273 | await new Promise((resolve) => setTimeout(resolve, 50)); 274 | expect(get(myState).isLoading).toBe(true); 275 | myParent.set('c'); 276 | await new Promise((resolve) => setTimeout(resolve, 50)); 277 | expect(get(myState).isLoading).toBe(true); 278 | 279 | const finalValue = await myStore.load(); 280 | 281 | expect(everErrored).toBe(false); 282 | expect(setIncorrectly).toBe(false); 283 | expect(finalValue).toBe('c'); 284 | expect(get(myStore)).toBe('c'); 285 | expect(get(myState).isLoaded).toBe(true); 286 | }); 287 | 288 | it('can be cleared correctly', async () => { 289 | const load = (value: string) => { 290 | return new Promise((resolve) => 291 | setTimeout(() => resolve(value), 100) 292 | ); 293 | }; 294 | 295 | const rebouncedLoad = rebounce(load); 296 | const myParent = writable(); 297 | const { store: myStore, state: myState } = asyncDerived( 298 | myParent, 299 | rebouncedLoad, 300 | { 301 | trackState: true, 302 | } 303 | ); 304 | 305 | myStore.subscribe(vi.fn()); 306 | 307 | myParent.set('one'); 308 | let loadValue = await myStore.load(); 309 | expect(loadValue).toBe('one'); 310 | 311 | myParent.set('two'); 312 | await new Promise((resolve) => setTimeout(resolve, 50)); 313 | rebouncedLoad.clear(); 314 | loadValue = await myStore.load(); 315 | 316 | expect(loadValue).toBe('one'); 317 | expect(get(myStore)).toBe('one'); 318 | expect(get(myState).isLoaded).toBe(true); 319 | }); 320 | 321 | it('rejects load when rebounce reject', () => { 322 | const rebouncedReject = rebounce( 323 | () => Promise.reject(new Error('error')), 324 | 100 325 | ); 326 | const parent = writable(); 327 | const rejectStore = asyncDerived(parent, () => rebouncedReject()); 328 | 329 | parent.set('value'); 330 | expect(() => rejectStore.load()).rejects.toStrictEqual( 331 | new Error('error') 332 | ); 333 | }); 334 | }); 335 | }); 336 | 337 | describe('multiple parents asyncDerived', () => { 338 | it('correctly derives from every kind of parent', async () => { 339 | const asyncReadableParent = asyncReadable(undefined, () => 340 | Promise.resolve('loadable') 341 | ); 342 | const reloadableParent = asyncReadable(undefined, mockReload, { 343 | reloadable: true, 344 | }); 345 | const myAsyncDerived = asyncDerived( 346 | [writableParent, asyncReadableParent, reloadableParent], 347 | ([$writableParent, $loadableParent, $reloadableParent]) => 348 | Promise.resolve( 349 | `derived from ${$writableParent}, ${$loadableParent}, ${$reloadableParent}` 350 | ) 351 | ); 352 | myAsyncDerived.subscribe(vi.fn()); 353 | 354 | await myAsyncDerived.load(); 355 | expect(get(myAsyncDerived)).toBe( 356 | 'derived from writable, loadable, first value' 357 | ); 358 | writableParent.set('new value'); 359 | await myAsyncDerived.load(); 360 | expect(get(myAsyncDerived)).toBe( 361 | 'derived from new value, loadable, first value' 362 | ); 363 | await myAsyncDerived.reload(); 364 | expect(get(myAsyncDerived)).toBe( 365 | 'derived from new value, loadable, second value' 366 | ); 367 | }); 368 | 369 | it('deterministically sets final value when receiving updates while loading', async () => { 370 | const delayedParent = asyncReadable( 371 | undefined, 372 | () => new Promise((resolve) => setTimeout(resolve, 1000)) 373 | ); 374 | const myDerived = asyncDerived( 375 | [writableParent, delayedParent], 376 | ([$writableParent, $delayedParent]) => 377 | mockReload().then((response) => `${$writableParent}: ${response}`) 378 | ); 379 | myDerived.subscribe(vi.fn()); 380 | writableParent.set('A'); 381 | writableParent.set('B'); 382 | writableParent.set('C'); 383 | writableParent.set('D'); 384 | writableParent.set('E'); 385 | writableParent.set('F'); 386 | writableParent.set('G'); 387 | writableParent.set('H'); 388 | writableParent.set('I'); 389 | writableParent.set('J'); 390 | writableParent.set('K'); 391 | writableParent.set('L'); 392 | await myDerived.load(); 393 | expect(get(myDerived)).toBe('L: first value'); 394 | }); 395 | }); 396 | 397 | describe('no parents asyncWritable', () => { 398 | it('sets given value when given void write function', async () => { 399 | const mappingWriteFunction = vi.fn(() => Promise.resolve()); 400 | const myAsyncWritable = asyncWritable( 401 | [], 402 | () => Promise.resolve('initial'), 403 | mappingWriteFunction 404 | ); 405 | myAsyncWritable.subscribe(vi.fn()); 406 | 407 | expect(myAsyncWritable.load()).resolves.toBe('initial'); 408 | await myAsyncWritable.load(); 409 | expect(get(myAsyncWritable)).toBe('initial'); 410 | 411 | await myAsyncWritable.set('final'); 412 | expect(get(myAsyncWritable)).toBe('final'); 413 | const loadedValue = await myAsyncWritable.load(); 414 | expect(loadedValue).toBe('final'); 415 | 416 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 417 | }); 418 | 419 | it('sets final value when given type returning write function', async () => { 420 | const mappingWriteFunction = vi.fn((value) => 421 | Promise.resolve(`resolved from ${value}`) 422 | ); 423 | const myAsyncWritable = asyncWritable( 424 | [], 425 | () => Promise.resolve('initial'), 426 | mappingWriteFunction 427 | ); 428 | myAsyncWritable.subscribe(vi.fn()); 429 | 430 | expect(myAsyncWritable.load()).resolves.toBe('initial'); 431 | await myAsyncWritable.load(); 432 | expect(get(myAsyncWritable)).toBe('initial'); 433 | 434 | await myAsyncWritable.set('intermediate'); 435 | expect(get(myAsyncWritable)).toBe('resolved from intermediate'); 436 | const loadedValue = await myAsyncWritable.load(); 437 | expect(loadedValue).toBe('resolved from intermediate'); 438 | 439 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 440 | }); 441 | 442 | it('sets value when reloadable', async () => { 443 | const mappingLoadFunction = vi.fn(() => Promise.resolve('load')); 444 | const mappingWriteFunction = vi.fn(() => Promise.resolve('write')); 445 | const myAsyncWritable = asyncWritable( 446 | [], 447 | mappingLoadFunction, 448 | mappingWriteFunction, 449 | { reloadable: true } 450 | ); 451 | myAsyncWritable.subscribe(vi.fn()); 452 | 453 | expect(myAsyncWritable.load()).resolves.toBe('load'); 454 | await myAsyncWritable.load(); 455 | expect(get(myAsyncWritable)).toBe('load'); 456 | 457 | await myAsyncWritable.set('set'); 458 | expect(get(myAsyncWritable)).toBe('write'); 459 | 460 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 461 | expect(mappingLoadFunction).toHaveBeenCalledTimes(1); 462 | }); 463 | 464 | it('still sets value when rejected', async () => { 465 | const mappingWriteFunction = vi.fn(() => 466 | Promise.reject(new Error('any')) 467 | ); 468 | const myAsyncWritable = asyncWritable( 469 | [], 470 | () => Promise.resolve('initial'), 471 | mappingWriteFunction 472 | ); 473 | myAsyncWritable.subscribe(vi.fn()); 474 | 475 | expect(myAsyncWritable.load()).resolves.toBe('initial'); 476 | await myAsyncWritable.load(); 477 | expect(get(myAsyncWritable)).toBe('initial'); 478 | 479 | await myAsyncWritable.set('final').catch(() => Promise.resolve()); 480 | expect(get(myAsyncWritable)).toBe('final'); 481 | const loadedValue = await myAsyncWritable.load(); 482 | expect(loadedValue).toBe('final'); 483 | 484 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 485 | }); 486 | 487 | it('provides old value of store to mapping write function', async () => { 488 | const dataFetchFunction = vi.fn(() => Promise.reject(new Error('any'))); 489 | const myAsyncWritable = asyncWritable( 490 | [], 491 | () => Promise.resolve('initial'), 492 | async (newValue, parentValues, oldValue) => { 493 | try { 494 | return await dataFetchFunction(); 495 | } catch { 496 | return oldValue; 497 | } 498 | } 499 | ); 500 | myAsyncWritable.subscribe(vi.fn()); 501 | 502 | expect(myAsyncWritable.load()).resolves.toBe('initial'); 503 | await myAsyncWritable.load(); 504 | expect(get(myAsyncWritable)).toBe('initial'); 505 | 506 | await myAsyncWritable.set('final').catch(() => Promise.resolve()); 507 | expect(get(myAsyncWritable)).toBe('initial'); 508 | const loadedValue = await myAsyncWritable.load(); 509 | expect(loadedValue).toBe('initial'); 510 | 511 | expect(dataFetchFunction).toHaveBeenCalledTimes(1); 512 | }); 513 | 514 | it('allows writing without invoking mappingWriteFunction', async () => { 515 | const dataFetchFunction = vi.fn(() => Promise.reject(new Error('any'))); 516 | const myAsyncWritable = asyncWritable( 517 | [], 518 | () => Promise.resolve('initial'), 519 | dataFetchFunction 520 | ); 521 | myAsyncWritable.subscribe(vi.fn()); 522 | 523 | expect(myAsyncWritable.load()).resolves.toBe('initial'); 524 | await myAsyncWritable.load(); 525 | expect(get(myAsyncWritable)).toBe('initial'); 526 | 527 | try { 528 | await myAsyncWritable.set('final'); 529 | } catch { 530 | // no idea why this needs to be caught 531 | await myAsyncWritable.set('error', false); 532 | } 533 | expect(get(myAsyncWritable)).toBe('error'); 534 | 535 | expect(dataFetchFunction).toHaveBeenCalledTimes(1); 536 | }); 537 | 538 | it('updates to expected value', async () => { 539 | const mappingWriteFunction = vi.fn(() => Promise.resolve()); 540 | const myAsyncWritable = asyncWritable( 541 | [], 542 | () => Promise.resolve('initial'), 543 | mappingWriteFunction 544 | ); 545 | myAsyncWritable.subscribe(vi.fn()); 546 | 547 | await myAsyncWritable.update((value) => `updated from ${value}`); 548 | expect(get(myAsyncWritable)).toBe('updated from initial'); 549 | const loadedValue = await myAsyncWritable.load(); 550 | expect(loadedValue).toBe('updated from initial'); 551 | 552 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 553 | }); 554 | }); 555 | 556 | describe('asyncWritable with parents', () => { 557 | it('loads expected value', async () => { 558 | const mappingWriteFunction = vi.fn(() => Promise.resolve()); 559 | const myAsyncWritable = asyncWritable( 560 | writableParent, 561 | (storeValue) => Promise.resolve(`derived from ${storeValue}`), 562 | mappingWriteFunction 563 | ); 564 | myAsyncWritable.subscribe(vi.fn()); 565 | 566 | expect(myAsyncWritable.load()).resolves.toBe('derived from writable'); 567 | await myAsyncWritable.load(); 568 | expect(get(myAsyncWritable)).toBe('derived from writable'); 569 | 570 | await myAsyncWritable.set('final'); 571 | expect(get(myAsyncWritable)).toBe('final'); 572 | 573 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 574 | }); 575 | 576 | it('still sets value when rejected', async () => { 577 | const mappingWriteFunction = vi.fn(() => 578 | Promise.reject(new Error('any')) 579 | ); 580 | const myAsyncWritable = asyncWritable( 581 | writableParent, 582 | () => Promise.reject(new Error('error')), 583 | mappingWriteFunction, 584 | { initial: 'initial' } 585 | ); 586 | myAsyncWritable.subscribe(vi.fn()); 587 | 588 | expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); 589 | await myAsyncWritable.load().catch(() => Promise.resolve()); 590 | expect(get(myAsyncWritable)).toBe('initial'); 591 | 592 | await myAsyncWritable.set('final').catch(() => Promise.resolve()); 593 | expect(get(myAsyncWritable)).toBe('final'); 594 | 595 | expect(mappingWriteFunction).toHaveBeenCalledTimes(1); 596 | }); 597 | 598 | it('does not reload if not reloadable', () => { 599 | const myAsyncWritable = asyncWritable(writableParent, mockReload, () => 600 | Promise.resolve() 601 | ); 602 | myAsyncWritable.subscribe(vi.fn()); 603 | 604 | expect(myAsyncWritable.load()).resolves.toBe('first value'); 605 | expect(isReloadable(myAsyncWritable)).toBeFalsy(); 606 | }); 607 | 608 | it('does reload if reloadable', async () => { 609 | const myAsyncWritable = asyncWritable( 610 | writableParent, 611 | mockReload, 612 | () => Promise.resolve(), 613 | { reloadable: true } 614 | ); 615 | myAsyncWritable.subscribe(vi.fn()); 616 | 617 | expect(myAsyncWritable.load()).resolves.toBe('first value'); 618 | await myAsyncWritable.load(); 619 | await myAsyncWritable.reload(); 620 | expect(get(myAsyncWritable)).toBe('second value'); 621 | expect(myAsyncWritable.load()).resolves.toBe('second value'); 622 | }); 623 | 624 | it('does reload if parent updates', async () => { 625 | const myAsyncWritable = asyncWritable(writableParent, mockReload, () => 626 | Promise.resolve() 627 | ); 628 | myAsyncWritable.subscribe(vi.fn()); 629 | 630 | await myAsyncWritable.load(); 631 | expect(get(myAsyncWritable)).toBe('first value'); 632 | writableParent.set('updated'); 633 | await myAsyncWritable.load(); 634 | expect(get(myAsyncWritable)).toBe('second value'); 635 | }); 636 | 637 | it('loads asyncReadable parent', () => { 638 | const asyncReadableParent = asyncReadable(undefined, mockReload); 639 | const myAsyncWritable = asyncWritable( 640 | asyncReadableParent, 641 | (storeValue) => `derived from ${storeValue}`, 642 | () => Promise.resolve() 643 | ); 644 | myAsyncWritable.subscribe(vi.fn()); 645 | 646 | expect(myAsyncWritable.load()).resolves.toBe('derived from first value'); 647 | expect(isReloadable(myAsyncWritable)).toBeFalsy(); 648 | }); 649 | 650 | it('can access asyncReadable parent loaded value while writing', async () => { 651 | const asyncReadableParent = asyncReadable(undefined, mockReload); 652 | const myAsyncWritable = asyncWritable( 653 | asyncReadableParent, 654 | (storeValue) => `derived from ${storeValue}`, 655 | (value, $asyncReadableParent) => 656 | Promise.resolve( 657 | `constructed from ${value} and ${$asyncReadableParent}` 658 | ) 659 | ); 660 | myAsyncWritable.subscribe(vi.fn()); 661 | 662 | await myAsyncWritable.set('set value'); 663 | expect(get(myAsyncWritable)).toBe( 664 | 'constructed from set value and first value' 665 | ); 666 | }); 667 | 668 | it('provides a single asyncReadable parent value if parent is not an array', async () => { 669 | const asyncReadableParent = asyncReadable(undefined, mockReload); 670 | const myAsyncWritable = asyncWritable( 671 | asyncReadableParent, 672 | (storeValue) => `derived from ${storeValue}`, 673 | (_, $asyncReadableParent) => 674 | Promise.resolve(`${typeof $asyncReadableParent}`) 675 | ); 676 | myAsyncWritable.subscribe(vi.fn()); 677 | 678 | await myAsyncWritable.set('set value'); 679 | expect(get(myAsyncWritable)).toBe('string'); 680 | }); 681 | 682 | it('provides an array as parent value if asyncReadable has a parents array', async () => { 683 | const asyncReadableParent = asyncReadable(undefined, mockReload); 684 | const myAsyncWritable = asyncWritable( 685 | [asyncReadableParent], 686 | (storeValue) => `derived from ${storeValue}`, 687 | (_, $asyncReadableParent) => 688 | Promise.resolve(`is an array: ${Array.isArray($asyncReadableParent)}`) 689 | ); 690 | myAsyncWritable.subscribe(vi.fn()); 691 | 692 | await myAsyncWritable.set('set value'); 693 | expect(get(myAsyncWritable)).toBe('is an array: true'); 694 | }); 695 | 696 | it('reloads reloadable parent', async () => { 697 | const asyncReadableParent = asyncReadable(undefined, mockReload, { 698 | reloadable: true, 699 | }); 700 | const myAsyncWritable: WritableLoadable = asyncWritable( 701 | asyncReadableParent, 702 | (storeValue) => `derived from ${storeValue}`, 703 | () => Promise.resolve(), 704 | { reloadable: true } 705 | ); 706 | myAsyncWritable.subscribe(vi.fn()); 707 | 708 | await myAsyncWritable.load(); 709 | expect(get(myAsyncWritable)).toBe('derived from first value'); 710 | await myAsyncWritable.reload(); 711 | expect(get(myAsyncWritable)).toBe('derived from second value'); 712 | expect(myAsyncWritable.load()).resolves.toBe('derived from second value'); 713 | 714 | await myAsyncWritable.set('set value'); 715 | expect(get(myAsyncWritable)).toBe('set value'); 716 | }); 717 | 718 | it('rejects load when parent load fails', () => { 719 | const asyncReadableParent = asyncReadable(undefined, () => 720 | Promise.reject(new Error('error')) 721 | ); 722 | const myAsyncWritable = asyncWritable( 723 | asyncReadableParent, 724 | (storeValue) => Promise.resolve(`derived from ${storeValue}`), 725 | () => Promise.resolve() 726 | ); 727 | myAsyncWritable.subscribe(vi.fn()); 728 | 729 | expect(myAsyncWritable.load()).rejects.toStrictEqual(new Error('error')); 730 | }); 731 | }); 732 | 733 | describe('error logging', () => { 734 | afterEach(() => { 735 | logAsyncErrors(undefined); 736 | }); 737 | 738 | it('does not call error logger when no error', async () => { 739 | const errorLogger = vi.fn(); 740 | logAsyncErrors(errorLogger); 741 | 742 | const myReadable = asyncReadable(undefined, () => 743 | Promise.resolve('value') 744 | ); 745 | await myReadable.load(); 746 | 747 | expect(errorLogger).not.toHaveBeenCalled(); 748 | }); 749 | 750 | it('does call error logger when async error', async () => { 751 | const errorLogger = vi.fn(); 752 | logAsyncErrors(errorLogger); 753 | 754 | const myReadable = asyncReadable(undefined, () => 755 | Promise.reject(new Error('error')) 756 | ); 757 | 758 | // perform multiple loads and make sure logger only called once 759 | await safeLoad(myReadable); 760 | await safeLoad(myReadable); 761 | await safeLoad(myReadable); 762 | 763 | expect(errorLogger).toHaveBeenCalledWith(new Error('error')); 764 | expect(errorLogger).toHaveBeenCalledTimes(1); 765 | }); 766 | }); 767 | }); 768 | 769 | describe('trackState', () => { 770 | describe('provides `store` self reference', () => { 771 | it('asyncWritable', () => { 772 | const { store } = asyncWritable(null, vi.fn(), vi.fn(), { 773 | reloadable: true, 774 | }); 775 | 776 | expect( 777 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 778 | ).toBeTruthy(); 779 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 780 | expect( 781 | Object.prototype.hasOwnProperty.call(store, 'reload') 782 | ).toBeTruthy(); 783 | expect(Object.prototype.hasOwnProperty.call(store, 'set')).toBeTruthy(); 784 | expect( 785 | Object.prototype.hasOwnProperty.call(store, 'update') 786 | ).toBeTruthy(); 787 | }); 788 | 789 | it('asyncDerived', () => { 790 | const { store } = asyncDerived([], vi.fn(), { 791 | reloadable: true, 792 | }); 793 | 794 | expect( 795 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 796 | ).toBeTruthy(); 797 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 798 | expect( 799 | Object.prototype.hasOwnProperty.call(store, 'reload') 800 | ).toBeTruthy(); 801 | }); 802 | 803 | it('asyncReadable', () => { 804 | const { store } = asyncReadable([], vi.fn(), { 805 | reloadable: true, 806 | }); 807 | 808 | expect( 809 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 810 | ).toBeTruthy(); 811 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 812 | expect( 813 | Object.prototype.hasOwnProperty.call(store, 'reload') 814 | ).toBeTruthy(); 815 | }); 816 | 817 | it('derived', () => { 818 | const { store } = derived([], vi.fn()); 819 | 820 | expect( 821 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 822 | ).toBeTruthy(); 823 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 824 | }); 825 | 826 | it('writable', () => { 827 | const { store } = writable(); 828 | 829 | expect( 830 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 831 | ).toBeTruthy(); 832 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 833 | expect(Object.prototype.hasOwnProperty.call(store, 'set')).toBeTruthy(); 834 | expect( 835 | Object.prototype.hasOwnProperty.call(store, 'update') 836 | ).toBeTruthy(); 837 | }); 838 | 839 | it('readable', () => { 840 | const { store } = readable(); 841 | 842 | expect( 843 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 844 | ).toBeTruthy(); 845 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 846 | }); 847 | 848 | it('persisted', () => { 849 | const { store } = persisted(null, 'key'); 850 | 851 | expect( 852 | Object.prototype.hasOwnProperty.call(store, 'subscribe') 853 | ).toBeTruthy(); 854 | expect(Object.prototype.hasOwnProperty.call(store, 'load')).toBeTruthy(); 855 | expect(Object.prototype.hasOwnProperty.call(store, 'set')).toBeTruthy(); 856 | expect( 857 | Object.prototype.hasOwnProperty.call(store, 'update') 858 | ).toBeTruthy(); 859 | }); 860 | }); 861 | 862 | describe('adds state store when trackState enabled', () => { 863 | it('works with asyncWritable', async () => { 864 | const { store: myStore, state: myState } = asyncWritable( 865 | [], 866 | () => Promise.resolve('loaded value'), 867 | undefined, 868 | { initial: 'initial', trackState: true } 869 | ); 870 | 871 | expect(get(myStore)).toBe('initial'); 872 | expect(get(myState).isLoading).toBe(true); 873 | 874 | await myStore.load(); 875 | 876 | expect(get(myStore)).toBe('loaded value'); 877 | expect(get(myState).isLoaded).toBe(true); 878 | }); 879 | 880 | it('works with asyncDerived', async () => { 881 | const { store: myStore, state: myState } = asyncDerived( 882 | [], 883 | () => Promise.resolve('loaded value'), 884 | { initial: 'initial', trackState: true } 885 | ); 886 | 887 | expect(get(myStore)).toBe('initial'); 888 | expect(get(myState).isLoading).toBe(true); 889 | 890 | await myStore.load(); 891 | 892 | expect(get(myStore)).toBe('loaded value'); 893 | expect(get(myState).isLoaded).toBe(true); 894 | }); 895 | 896 | it('works with asyncReadable', async () => { 897 | const { store: myStore, state: myState } = asyncReadable( 898 | 'initial', 899 | () => Promise.resolve('loaded value'), 900 | { trackState: true } 901 | ); 902 | 903 | expect(get(myStore)).toBe('initial'); 904 | expect(get(myState).isLoading).toBe(true); 905 | 906 | await myStore.load(); 907 | 908 | expect(get(myStore)).toBe('loaded value'); 909 | expect(get(myState).isLoaded).toBe(true); 910 | }); 911 | }); 912 | 913 | describe('RELOADING state', () => { 914 | it('tracks reloading', async () => { 915 | const { store: myStore, state: myState } = asyncReadable( 916 | 'initial', 917 | () => Promise.resolve('loaded value'), 918 | { reloadable: true, trackState: true } 919 | ); 920 | 921 | expect(get(myState).isLoading).toBe(true); 922 | 923 | await myStore.load(); 924 | 925 | expect(get(myState).isLoaded).toBe(true); 926 | 927 | const reloadPromise = myStore.reload(); 928 | 929 | expect(get(myState).isReloading).toBe(true); 930 | 931 | await reloadPromise; 932 | 933 | expect(get(myState).isLoaded).toBe(true); 934 | }); 935 | 936 | it('tracks reloading of reloadable parent', async () => { 937 | const parentLoad = vi 938 | .fn() 939 | .mockResolvedValueOnce('first load') 940 | .mockResolvedValueOnce('second load'); 941 | const myParent = asyncReadable('initial', parentLoad, { 942 | reloadable: true, 943 | }); 944 | const { store: myStore, state: myState } = asyncDerived( 945 | myParent, 946 | ($myParent) => Promise.resolve(`derived from ${$myParent}`), 947 | { trackState: true } 948 | ); 949 | 950 | expect(get(myState).isLoading).toBe(true); 951 | 952 | await myStore.load(); 953 | 954 | expect(get(myStore)).toBe('derived from first load'); 955 | expect(get(myState).isLoaded).toBe(true); 956 | 957 | const reloadPromise = myStore.reload(); 958 | 959 | expect(get(myStore)).toBe('derived from first load'); 960 | expect(get(myState).isReloading).toBe(true); 961 | 962 | await reloadPromise; 963 | 964 | expect(get(myStore)).toBe('derived from second load'); 965 | expect(get(myState).isLoaded).toBe(true); 966 | }); 967 | 968 | it('tracks reloading of reloadable parent when no change', async () => { 969 | const parentLoad = vi.fn().mockResolvedValue('load'); 970 | const myParent = asyncReadable('initial', parentLoad, { 971 | reloadable: true, 972 | }); 973 | const { store: myStore, state: myState } = asyncDerived( 974 | myParent, 975 | ($myParent) => Promise.resolve(`derived from ${$myParent}`), 976 | { trackState: true } 977 | ); 978 | 979 | expect(get(myState).isLoading).toBe(true); 980 | 981 | await myStore.load(); 982 | 983 | expect(get(myStore)).toBe('derived from load'); 984 | expect(get(myState).isLoaded).toBe(true); 985 | 986 | const reloadPromise = myStore.reload(); 987 | 988 | expect(get(myStore)).toBe('derived from load'); 989 | expect(get(myState).isReloading).toBe(true); 990 | 991 | await reloadPromise; 992 | 993 | expect(get(myStore)).toBe('derived from load'); 994 | expect(get(myState).isLoaded).toBe(true); 995 | }); 996 | 997 | it('tracks automatic reloading when parent change', async () => { 998 | const myParent = writable('initial'); 999 | const { store: myStore, state: myState } = asyncDerived( 1000 | myParent, 1001 | ($myParent) => 1002 | new Promise((resolve) => 1003 | setTimeout(() => resolve(`derived from ${$myParent}`), 50) 1004 | ), 1005 | { trackState: true } 1006 | ); 1007 | 1008 | myStore.subscribe(vi.fn()); 1009 | 1010 | expect(get(myState).isLoading).toBe(true); 1011 | 1012 | await myStore.load(); 1013 | 1014 | expect(get(myStore)).toBe('derived from initial'); 1015 | expect(get(myState).isLoaded).toBe(true); 1016 | 1017 | myParent.set('updated'); 1018 | await new Promise((resolve) => setTimeout(resolve)); 1019 | 1020 | expect(get(myStore)).toBe('derived from initial'); 1021 | expect(get(myState).isReloading).toBe(true); 1022 | 1023 | await myStore.load(); 1024 | 1025 | expect(get(myStore)).toBe('derived from updated'); 1026 | expect(get(myState).isLoaded).toBe(true); 1027 | }); 1028 | 1029 | it('tracks reloading with multiple parent updates', async () => { 1030 | const grandParent = writable('initial'); 1031 | const parentA = derived( 1032 | grandParent, 1033 | ($grandParent) => `${$grandParent}A` 1034 | ); 1035 | const parentB = derived( 1036 | grandParent, 1037 | ($grandParent) => `${$grandParent}B` 1038 | ); 1039 | const { store: myStore, state: myState } = asyncDerived( 1040 | [parentA, parentB], 1041 | ([$parentA, $parentB]) => { 1042 | return new Promise((resolve) => { 1043 | setTimeout(() => { 1044 | resolve(`${$parentA} ${$parentB}`); 1045 | }, 100); 1046 | }); 1047 | }, 1048 | { trackState: true } 1049 | ); 1050 | 1051 | expect(get(myState).isLoading).toBe(true); 1052 | 1053 | await myStore.load(); 1054 | 1055 | expect(get(myStore)).toBe('initialA initialB'); 1056 | expect(get(myState).isLoaded).toBe(true); 1057 | 1058 | grandParent.set('updated'); 1059 | await new Promise((resolve) => setTimeout(resolve)); 1060 | 1061 | expect(get(myStore)).toBe('initialA initialB'); 1062 | expect(get(myState).isReloading).toBe(true); 1063 | 1064 | await myStore.load(); 1065 | 1066 | expect(get(myStore)).toBe('updatedA updatedB'); 1067 | expect(get(myState).isLoaded).toBe(true); 1068 | }); 1069 | }); 1070 | 1071 | describe('ERROR state', () => { 1072 | it('tracks error of loadable', async () => { 1073 | const { store: myStore, state: myState } = asyncReadable( 1074 | 'initial', 1075 | () => Promise.reject(new Error('error')), 1076 | { trackState: true } 1077 | ); 1078 | expect(get(myState).isLoading).toBe(true); 1079 | 1080 | await safeLoad(myStore); 1081 | 1082 | expect(get(myState).isError).toBe(true); 1083 | }); 1084 | 1085 | it('tracks error during reload', async () => { 1086 | const load = vi 1087 | .fn() 1088 | .mockResolvedValueOnce('success') 1089 | .mockRejectedValueOnce('failure'); 1090 | const { store: myStore, state: myState } = asyncReadable( 1091 | 'initial', 1092 | load, 1093 | { trackState: true, reloadable: true } 1094 | ); 1095 | expect(get(myState).isLoading).toBe(true); 1096 | 1097 | await safeLoad(myStore); 1098 | 1099 | expect(get(myState).isLoaded).toBe(true); 1100 | 1101 | await myStore.reload().catch(vi.fn()); 1102 | 1103 | expect(get(myState).isError).toBe(true); 1104 | }); 1105 | 1106 | it('tracks error during parent load', async () => { 1107 | const parentLoad = vi 1108 | .fn() 1109 | .mockResolvedValueOnce('success') 1110 | .mockRejectedValueOnce('failure'); 1111 | const myParent = asyncReadable('initial', parentLoad, { 1112 | reloadable: true, 1113 | }); 1114 | const { store: myStore, state: myState } = asyncDerived( 1115 | myParent, 1116 | ($myParent) => Promise.resolve(`derived from ${$myParent}`), 1117 | { trackState: true } 1118 | ); 1119 | 1120 | expect(get(myState).isLoading).toBe(true); 1121 | 1122 | await safeLoad(myStore); 1123 | 1124 | expect(get(myState).isLoaded).toBe(true); 1125 | 1126 | await myStore.reload().catch(vi.fn()); 1127 | 1128 | expect(get(myState).isError).toBe(true); 1129 | }); 1130 | }); 1131 | 1132 | describe('WRITING state', () => { 1133 | it('tracks writing', async () => { 1134 | const { store: myStore, state: myState } = asyncWritable( 1135 | [], 1136 | () => Promise.resolve('loaded value'), 1137 | () => Promise.resolve('final value'), 1138 | { trackState: true } 1139 | ); 1140 | 1141 | expect(get(myState).isLoading).toBe(true); 1142 | 1143 | await myStore.load(); 1144 | 1145 | expect(get(myState).isLoaded).toBe(true); 1146 | 1147 | const setPromise = myStore.set('intermediate value'); 1148 | 1149 | expect(get(myState).isWriting).toBe(true); 1150 | 1151 | await setPromise; 1152 | 1153 | expect(get(myState).isLoaded).toBe(true); 1154 | }); 1155 | 1156 | it('tracks writing error', async () => { 1157 | const { store: myStore, state: myState } = asyncWritable( 1158 | [], 1159 | () => Promise.resolve('loaded value'), 1160 | () => Promise.reject(new Error('rejection')), 1161 | { trackState: true } 1162 | ); 1163 | 1164 | expect(get(myState).isLoading).toBe(true); 1165 | 1166 | await myStore.load(); 1167 | 1168 | expect(get(myState).isLoaded).toBe(true); 1169 | 1170 | const setPromise = myStore.set('intermediate value'); 1171 | 1172 | expect(get(myState).isWriting).toBe(true); 1173 | 1174 | await setPromise.catch(vi.fn()); 1175 | 1176 | expect(get(myState).isError).toBe(true); 1177 | }); 1178 | }); 1179 | }); 1180 | --------------------------------------------------------------------------------