From 4e5910d398b4d459c0da781e593cfe7218dcaad1 Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 02:56:09 +0200 Subject: [PATCH 1/6] feat: add LazyState --- packages/runed/src/lib/utilities/index.ts | 10 +- .../src/lib/utilities/lazy-state/index.ts | 1 + .../lazy-state/lazy-state.svelte.test.ts | 24 ++++ .../utilities/lazy-state/lazy-state.svelte.ts | 36 ++++++ packages/runed/tsconfig.json | 3 +- pnpm-lock.yaml | 12 ++ sites/docs/package.json | 1 + .../docs/src/content/utilities/lazy-state.md | 46 ++++++++ .../lib/components/demos/lazy-state.svelte | 108 ++++++++++++++++++ 9 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 packages/runed/src/lib/utilities/lazy-state/index.ts create mode 100644 packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.test.ts create mode 100644 packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts create mode 100644 sites/docs/src/content/utilities/lazy-state.md create mode 100644 sites/docs/src/lib/components/demos/lazy-state.svelte 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..0eac1c73 --- /dev/null +++ b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.test.ts @@ -0,0 +1,24 @@ +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(factory).toHaveBeenCalledTimes(0); + + expect(counter.current).toBe(0); + expect(factory).toHaveBeenCalledTimes(1); + + expect(counter.current).toBe(0); + 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(factory).toHaveBeenCalledTimes(0); + }); +}); 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..aaf5b153 --- /dev/null +++ b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts @@ -0,0 +1,36 @@ +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 = 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; + } +} 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..8825a9ed --- /dev/null +++ b/sites/docs/src/content/utilities/lazy-state.md @@ -0,0 +1,46 @@ +--- +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()); + +// Accessing the `current` property for the first time initializes it +// with the result of the `performExpensiveComputation` function. +expensiveValue.current; + +// 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); +} +``` 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..dfbc65d2 --- /dev/null +++ b/sites/docs/src/lib/components/demos/lazy-state.svelte @@ -0,0 +1,108 @@ + + +{#snippet treeitems(nodes: TreeNode[], parent: FolderNode | null, depth: number)} + {#each nodes as node, i (node)} +
+ + + {#if node.type === "file"} + + {:else if node.type === "folder"} + + {/if} + + {node.name} + +
+ + +
+ + {#if node.type === "folder" && node.expanded} + {@render treeitems(node.children.current, node, depth + 1)} + {/if} + {/each} +{/snippet} + + +
+ {@render treeitems(root, null, 0)} +
+
From 88d469a6bd8de89106ac7636ee8adb6643f6794c Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 06:49:53 +0200 Subject: [PATCH 2/6] fix(docs): remove unnecessary div --- sites/docs/src/lib/components/demos/lazy-state.svelte | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sites/docs/src/lib/components/demos/lazy-state.svelte b/sites/docs/src/lib/components/demos/lazy-state.svelte index dfbc65d2..c81c3971 100644 --- a/sites/docs/src/lib/components/demos/lazy-state.svelte +++ b/sites/docs/src/lib/components/demos/lazy-state.svelte @@ -102,7 +102,5 @@ {/snippet} -
- {@render treeitems(root, null, 0)} -
+ {@render treeitems(root, null, 0)}
From 2cf70734b8a27125d430e30305bef6e1b14575b5 Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 06:50:34 +0200 Subject: [PATCH 3/6] chore: add changeset --- .changeset/slow-sites-clean.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/slow-sites-clean.md 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 From c2e08aff58661e37d81486942b90cdc6caa48a11 Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 06:51:55 +0200 Subject: [PATCH 4/6] style: prettier was mad --- sites/docs/src/content/utilities/lazy-state.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sites/docs/src/content/utilities/lazy-state.md b/sites/docs/src/content/utilities/lazy-state.md index 8825a9ed..e26aa113 100644 --- a/sites/docs/src/content/utilities/lazy-state.md +++ b/sites/docs/src/content/utilities/lazy-state.md @@ -14,7 +14,8 @@ This is useful for deferring expensive initializations until they're actually ne ## Demo -This is a demo of a large tree whose children are lazily initialized. Check the console for initialization logs. +This is a demo of a large tree whose children are lazily initialized. Check the console for +initialization logs. From 21eed0a89d40ef1b7436bb7644ca572b70fe2d46 Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 16:45:35 +0200 Subject: [PATCH 5/6] feat: expose initialized property, update docs --- .../lazy-state/lazy-state.svelte.test.ts | 4 + .../utilities/lazy-state/lazy-state.svelte.ts | 7 +- .../docs/src/content/utilities/lazy-state.md | 4 + .../lib/components/demos/lazy-state.svelte | 127 +++++++----------- 4 files changed, 64 insertions(+), 78 deletions(-) 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 index 0eac1c73..6db1c908 100644 --- 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 @@ -5,12 +5,15 @@ 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); }); @@ -19,6 +22,7 @@ describe("LazyState", () => { const counter = new LazyState(factory); counter.current = 1; expect(counter.current).toBe(1); + expect(counter.initialized).toBe(true); expect(factory).toHaveBeenCalledTimes(0); }); }); 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 index aaf5b153..39a87418 100644 --- a/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts +++ b/packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts @@ -16,7 +16,7 @@ export class LazyState { } #current: T | undefined = $state(); - #initialized = false; + #initialized = $state.raw(false); /** The current value of this object. */ get current(): T { @@ -33,4 +33,9 @@ export class LazyState { this.#current = value; this.#initialized = true; } + + /** Whether the `current` property has been initialized. */ + get initialized(): boolean { + return this.#initialized; + } } diff --git a/sites/docs/src/content/utilities/lazy-state.md b/sites/docs/src/content/utilities/lazy-state.md index e26aa113..29976cfe 100644 --- a/sites/docs/src/content/utilities/lazy-state.md +++ b/sites/docs/src/content/utilities/lazy-state.md @@ -25,10 +25,12 @@ initialization logs. 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; @@ -43,5 +45,7 @@ class LazyState { 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 index c81c3971..1843ac12 100644 --- a/sites/docs/src/lib/components/demos/lazy-state.svelte +++ b/sites/docs/src/lib/components/demos/lazy-state.svelte @@ -1,106 +1,79 @@ -{#snippet treeitems(nodes: TreeNode[], parent: FolderNode | null, depth: number)} - {#each nodes as node, i (node)} -
- - - {#if node.type === "file"} - - {:else if node.type === "folder"} - - {/if} - - {node.name} + {#if node instanceof FileNode} + + {:else} + + {/if} -
+ {node.name} - -
+ {#if node instanceof FolderNode && node.children.initialized} +
initialized
+ {/if} + + - {#if node.type === "folder" && node.expanded} - {@render treeitems(node.children.current, node, depth + 1)} + {#if node instanceof FolderNode && node.expanded} + {@render treeitems(node.children.current, depth + 1)} {/if} {/each} {/snippet} - - {@render treeitems(root, null, 0)} + + {@render treeitems(root, 0)} From 8efe60b5005b587574435e75c2c09ed78eb5d7bc Mon Sep 17 00:00:00 2001 From: abdel-17 Date: Tue, 23 Dec 2025 16:50:27 +0200 Subject: [PATCH 6/6] chore: add more tests --- .../utilities/lazy-state/lazy-state.svelte.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index 6db1c908..157660d6 100644 --- 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 @@ -25,4 +25,18 @@ describe("LazyState", () => { 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"); + }); });