Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
3 changed files with 157 additions and 0 deletions
Showing only changes of commit f0736f4d35 - Show all commits
@@ -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