diff --git a/src/shared/lib/helpers/createSingleton/createSingleton.test.ts b/src/shared/lib/helpers/createSingleton/createSingleton.test.ts new file mode 100644 index 0000000..01efc82 --- /dev/null +++ b/src/shared/lib/helpers/createSingleton/createSingleton.test.ts @@ -0,0 +1,86 @@ +import { + describe, + expect, + it, + vi, +} from 'vitest'; +import { createSingleton } from './createSingleton'; + +describe('createSingleton', () => { + it('does not call the factory until the first get (lazy)', () => { + const factory = vi.fn(() => ({ id: 1 })); + createSingleton(factory); + expect(factory).not.toHaveBeenCalled(); + }); + + it('constructs on first get and memoizes the instance', () => { + const factory = vi.fn(() => ({ id: 1 })); + const singleton = createSingleton(factory); + + const a = singleton.get(); + const b = singleton.get(); + + expect(factory).toHaveBeenCalledTimes(1); + expect(a).toBe(b); + }); + + it('rebuilds a fresh instance after reset', () => { + let count = 0; + const singleton = createSingleton(() => ({ id: ++count })); + + const first = singleton.get(); + singleton.reset(); + const second = singleton.get(); + + expect(first).not.toBe(second); + expect(second.id).toBe(2); + }); + + it('runs teardown once, with the live instance, on reset', () => { + const teardown = vi.fn(); + const singleton = createSingleton(() => ({ id: 1 }), teardown); + + const instance = singleton.get(); + singleton.reset(); + + expect(teardown).toHaveBeenCalledTimes(1); + expect(teardown).toHaveBeenCalledWith(instance); + }); + + it('treats reset before any get as a no-op (no teardown, no throw)', () => { + const teardown = vi.fn(); + const singleton = createSingleton(() => ({ id: 1 }), teardown); + + expect(() => singleton.reset()).not.toThrow(); + expect(teardown).not.toHaveBeenCalled(); + }); + + it('does not run teardown again on a second consecutive reset', () => { + const teardown = vi.fn(); + const singleton = createSingleton(() => ({ id: 1 }), teardown); + + singleton.get(); + singleton.reset(); + singleton.reset(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('works without a teardown', () => { + const singleton = createSingleton(() => ({ id: 1 })); + + singleton.get(); + expect(() => singleton.reset()).not.toThrow(); + expect(singleton.get().id).toBe(1); + }); + + it('caches a falsy instance value without re-running the factory', () => { + const factory = vi.fn(() => undefined); + const singleton = createSingleton(factory); + + singleton.get(); + singleton.get(); + + expect(factory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/lib/helpers/createSingleton/createSingleton.ts b/src/shared/lib/helpers/createSingleton/createSingleton.ts new file mode 100644 index 0000000..ef8294c --- /dev/null +++ b/src/shared/lib/helpers/createSingleton/createSingleton.ts @@ -0,0 +1,57 @@ +/** + * A lazily-constructed singleton accessor pair. + */ +export interface Singleton { + /** + * Returns the instance, constructing it on the first call and reusing it + * thereafter. + */ + get: () => T; + /** + * Tears down the current instance (if built) and clears it, so the next + * `get()` rebuilds. Used by specs to avoid shared state between tests. + */ + reset: () => void; +} + +/** + * Standardizes the lazy `getX()` / `__resetX()` singleton pattern used by the + * app's stores. + * + * The instance is built on the first `get()` and reused afterwards; `reset()` + * runs the optional teardown against the live instance and clears it. Building + * lazily keeps the owning module inert at import — construction happens only on + * first access, never at module eval. + * + * @param factory - Builds the instance on first access. + * @param teardown - Optional cleanup run against the live instance on reset + * (e.g. disposing an `$effect.root` via the instance's `destroy()`). + * + * @example + * ```ts + * const catalog = createSingleton(() => new FontCatalogStore({ limit: 50 }), c => c.destroy()); + * export const getFontCatalog = catalog.get; + * export const __resetFontCatalog = catalog.reset; + * ``` + */ +export function createSingleton(factory: () => T, teardown?: (instance: T) => void): Singleton { + let instance: T | undefined; + let initialized = false; + + return { + get: () => { + if (!initialized) { + instance = factory(); + initialized = true; + } + return instance as T; + }, + reset: () => { + if (initialized) { + teardown?.(instance as T); + } + instance = undefined; + initialized = false; + }, + }; +} diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index bba4aa4..17207d7 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -137,6 +137,20 @@ export { type PerspectiveManager, } from './createPerspectiveManager/createPerspectiveManager.svelte'; +/** + * Lazy singletons + */ +export { + /** + * Lazy `getX()` / `__resetX()` singleton accessor factory + */ + createSingleton, + /** + * Singleton accessor pair type + */ + type Singleton, +} from './createSingleton/createSingleton'; + /* * BaseQueryStore is intentionally NOT re-exported here. * It pulls @tanstack/query-core, so routing it through this leaf barrel would