diff --git a/.changeset/slow-sites-clean.md b/.changeset/slow-sites-clean.md new file mode 100644 index 00000000..436c8795 --- /dev/null +++ b/.changeset/slow-sites-clean.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +feat: add LazyState diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index 55f528b8..a98ef683 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -1,16 +1,19 @@ export * from "./active-element/index.js"; export * from "./animation-frames/index.js"; +export * from "./bool-attr/index.js"; export * from "./context/index.js"; export * from "./debounced/index.js"; export * from "./element-rect/index.js"; export * from "./element-size/index.js"; export * from "./extract/index.js"; export * from "./finite-state-machine/index.js"; +export * from "./is-document-visible/index.js"; export * from "./is-focus-within/index.js"; export * from "./is-idle/index.js"; export * from "./is-in-viewport/index.js"; export * from "./is-mounted/index.js"; -export * from "./is-document-visible/index.js"; +export * from "./lazy-state/index.js"; +export * from "./on-cleanup/index.js"; export * from "./on-click-outside/index.js"; export * from "./persisted-state/index.js"; export * from "./pressed-keys/index.js"; @@ -24,11 +27,8 @@ export * from "./use-debounce/index.js"; export * from "./use-event-listener/index.js"; export * from "./use-geolocation/index.js"; export * from "./use-intersection-observer/index.js"; +export * from "./use-interval/index.js"; export * from "./use-mutation-observer/index.js"; export * from "./use-resize-observer/index.js"; -export * from "./use-interval/index.js"; export * from "./use-throttle/index.js"; export * from "./watch/index.js"; -export * from "./scroll-state/index.js"; -export * from "./bool-attr/index.js"; -export * from "./on-cleanup/index.js"; diff --git a/packages/runed/src/lib/utilities/lazy-state/index.ts b/packages/runed/src/lib/utilities/lazy-state/index.ts new file mode 100644 index 00000000..3e3b47c3 --- /dev/null +++ b/packages/runed/src/lib/utilities/lazy-state/index.ts @@ -0,0 +1 @@ +export * from "./lazy-state.svelte.js"; diff --git a/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.test.ts b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.test.ts new file mode 100644 index 00000000..157660d6 --- /dev/null +++ b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.test.ts @@ -0,0 +1,42 @@ +import { describe, vi } from "vitest"; +import { LazyState } from "./lazy-state.svelte.js"; + +describe("LazyState", () => { + it("calls the factory only when `current` is first accessed", () => { + const factory = vi.fn(() => 0); + const counter = new LazyState(factory); + expect(counter.initialized).toBe(false); + expect(factory).toHaveBeenCalledTimes(0); + + expect(counter.current).toBe(0); + expect(counter.initialized).toBe(true); + expect(factory).toHaveBeenCalledTimes(1); + + expect(counter.current).toBe(0); + expect(counter.initialized).toBe(true); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it("does not call the factory when `current` is set", () => { + const factory = vi.fn(() => 0); + const counter = new LazyState(factory); + counter.current = 1; + expect(counter.current).toBe(1); + expect(counter.initialized).toBe(true); + expect(factory).toHaveBeenCalledTimes(0); + }); + + it("is reactive", () => { + const counter = new LazyState(() => 1); + const doubled = $derived(counter.current * 2); + const message = $derived(counter.initialized ? "initialized" : "not initialized"); + expect(message).toBe("not initialized"); + + expect(doubled).toBe(2); + expect(message).toBe("initialized"); + + counter.current = 2; + expect(doubled).toBe(4); + expect(message).toBe("initialized"); + }); +}); diff --git a/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts new file mode 100644 index 00000000..39a87418 --- /dev/null +++ b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts @@ -0,0 +1,41 @@ +import { untrack } from "svelte"; + +/** + * A stateful object that is lazily initialized. + * + * @see {@link https://runed.dev/docs/utilities/lazy-state} + */ +export class LazyState { + readonly #factory: () => T; + + /** + * @param factory - A function that returns the initial value of this object. + */ + constructor(factory: () => T) { + this.#factory = factory; + } + + #current: T | undefined = $state(); + #initialized = $state.raw(false); + + /** The current value of this object. */ + get current(): T { + if (!this.#initialized) { + untrack(() => { + this.#current = this.#factory(); + this.#initialized = true; + }); + } + return this.#current!; + } + + set current(value: T) { + this.#current = value; + this.#initialized = true; + } + + /** Whether the `current` property has been initialized. */ + get initialized(): boolean { + return this.#initialized; + } +} diff --git a/packages/runed/tsconfig.json b/packages/runed/tsconfig.json index 0ee372a3..ff8194e6 100644 --- a/packages/runed/tsconfig.json +++ b/packages/runed/tsconfig.json @@ -12,8 +12,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "noUncheckedIndexedAccess": true, - "types": ["vitest/globals"], - "experimentalDecorators": true + "types": ["vitest/globals"] }, "include": [ "./.svelte-kit/ambient.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c392eeac..f74dc689 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: sites/docs: devDependencies: + '@lucide/svelte': + specifier: ^0.562.0 + version: 0.562.0(svelte@5.39.3) '@svecodocs/kit': specifier: ^0.5.1 version: 0.5.1(@sveltejs/kit@2.42.2(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.39.3)(vite@7.1.6(@types/node@20.19.17)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.36.0)(tsx@4.20.5)(yaml@2.6.1)))(svelte@5.39.3)(vite@7.1.6(@types/node@20.19.17)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.36.0)(tsx@4.20.5)(yaml@2.6.1)))(bits-ui@1.0.0-next.65(svelte@5.39.3))(svelte@5.39.3)(tailwindcss@4.1.13)(vite@7.1.6(@types/node@20.19.17)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.36.0)(tsx@4.20.5)(yaml@2.6.1)) @@ -941,6 +944,11 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lucide/svelte@0.562.0': + resolution: {integrity: sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw==} + peerDependencies: + svelte: ^5 + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -4453,6 +4461,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lucide/svelte@0.562.0(svelte@5.39.3)': + dependencies: + svelte: 5.39.3 + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.4 diff --git a/sites/docs/package.json b/sites/docs/package.json index cff0c2fc..bee67da4 100644 --- a/sites/docs/package.json +++ b/sites/docs/package.json @@ -30,6 +30,7 @@ "url": "https://github.com/svecosystem/runed.git" }, "devDependencies": { + "@lucide/svelte": "^0.562.0", "@svecodocs/kit": "^0.5.1", "@sveltejs/adapter-auto": "^6.0.1", "@sveltejs/adapter-cloudflare": "^7.0.3", diff --git a/sites/docs/src/content/utilities/lazy-state.md b/sites/docs/src/content/utilities/lazy-state.md new file mode 100644 index 00000000..29976cfe --- /dev/null +++ b/sites/docs/src/content/utilities/lazy-state.md @@ -0,0 +1,51 @@ +--- +title: LazyState +description: A stateful object that is lazily initialized. +category: State +--- + + + +The `LazyState` utility creates a reactive value that is only initialized when it is first accessed. + +This is useful for deferring expensive initializations until they're actually needed. + +## Demo + +This is a demo of a large tree whose children are lazily initialized. Check the console for +initialization logs. + + + +## Usage + +```ts +import { LazyState } from "runed"; + +const expensiveValue = new LazyState(() => performExpensiveComputation()); +expensiveValue.initialized === false; + +// Accessing the `current` property for the first time initializes it +// with the result of the `performExpensiveComputation` function. +expensiveValue.current; +expensiveValue.initialized === true; + +// Accessing the `current` property again does not call the function. +expensiveValue.current; +``` + +## Type Definition + +```ts +class LazyState { + constructor(factory: () => T); + + get current(): T; + + set current(value: T); + + get initialized(): boolean; +} +``` diff --git a/sites/docs/src/lib/components/demos/lazy-state.svelte b/sites/docs/src/lib/components/demos/lazy-state.svelte new file mode 100644 index 00000000..1843ac12 --- /dev/null +++ b/sites/docs/src/lib/components/demos/lazy-state.svelte @@ -0,0 +1,79 @@ + + +{#snippet treeitems(nodes: TreeNode[], depth: number)} + {#each nodes as node (node)} + + + {#if node instanceof FolderNode && node.expanded} + {@render treeitems(node.children.current, depth + 1)} + {/if} + {/each} +{/snippet} + + + {@render treeitems(root, 0)} +