feat(shared): add createSingleton lazy-singleton accessor helper

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.
This commit is contained in:
Ilia Mashkov
2026-06-03 09:39:51 +03:00
parent 5eb458eabb
commit f0736f4d35
3 changed files with 157 additions and 0 deletions
@@ -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<undefined>(factory);
singleton.get();
singleton.get();
expect(factory).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,57 @@
/**
* A lazily-constructed singleton accessor pair.
*/
export interface Singleton<T> {
/**
* 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<T>(factory: () => T, teardown?: (instance: T) => void): Singleton<T> {
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;
},
};
}
+14
View File
@@ -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