From f0736f4d35576c0df0c4be646cf621e13f37354c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 3 Jun 2026 09:39:51 +0300 Subject: [PATCH] feat(shared): add createSingleton lazy-singleton accessor helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardizes the getX() / __resetX() pattern hand-rolled identically across every store: lazy construction on first get(), memoized thereafter, and a reset() that runs an optional teardown (e.g. destroy()) and clears so the next get() rebuilds. Lazy by construction, so owning modules stay inert at import. Covered by unit tests (laziness, memoization, rebuild-after-reset, teardown-once-with-live-instance, reset-before-get no-op, falsy-value caching). Not yet adopted by the stores — that migration is a separate step. --- .../createSingleton/createSingleton.test.ts | 86 +++++++++++++++++++ .../createSingleton/createSingleton.ts | 57 ++++++++++++ src/shared/lib/helpers/index.ts | 14 +++ 3 files changed, 157 insertions(+) create mode 100644 src/shared/lib/helpers/createSingleton/createSingleton.test.ts create mode 100644 src/shared/lib/helpers/createSingleton/createSingleton.ts 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