Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-sites-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"runed": minor
---

feat: add LazyState
10 changes: 5 additions & 5 deletions packages/runed/src/lib/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
1 change: 1 addition & 0 deletions packages/runed/src/lib/utilities/lazy-state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./lazy-state.svelte.js";
Original file line number Diff line number Diff line change
@@ -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");
});
});
41 changes: 41 additions & 0 deletions packages/runed/src/lib/utilities/lazy-state/lazy-state.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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;
}
}
3 changes: 1 addition & 2 deletions packages/runed/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"types": ["vitest/globals"],
"experimentalDecorators": true
"types": ["vitest/globals"]
},
"include": [
"./.svelte-kit/ambient.d.ts",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sites/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions sites/docs/src/content/utilities/lazy-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: LazyState
description: A stateful object that is lazily initialized.
category: State
---

<script>
import Demo from '$lib/components/demos/lazy-state.svelte';
</script>

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.

<Demo />

## 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<T> {
constructor(factory: () => T);

get current(): T;

set current(value: T);

get initialized(): boolean;
}
```
79 changes: 79 additions & 0 deletions sites/docs/src/lib/components/demos/lazy-state.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script lang="ts">
import { FileIcon, FolderIcon } from "@lucide/svelte";
import { DemoContainer } from "@svecodocs/kit";
import { LazyState } from "runed";

class FileNode {
name: string;

constructor(name: string) {
this.name = $state.raw(name);
}
}

class FolderNode {
name: string;
children: LazyState<TreeNode[]>;
expanded = $state.raw(false);

constructor(name: string, children: () => TreeNode[]) {
this.name = $state.raw(name);
this.children = new LazyState(children);
}
}

type TreeNode = FileNode | FolderNode;

const root: TreeNode[] = $state([
new FolderNode("Pictures", () =>
Array(100)
.fill(null)
.map(
(_, i) =>
new FolderNode(`Folder ${i + 1}`, () =>
Array(100)
.fill(null)
.map((_, j) => new FileNode(`File ${j + 1}.png`))
)
)
),
new FileNode("README.md"),
]);

function onToggleExpansion(node: TreeNode) {
if (node instanceof FolderNode) {
node.expanded = !node.expanded;
}
}
</script>

{#snippet treeitems(nodes: TreeNode[], depth: number)}
{#each nodes as node (node)}
<button class="hover:bg-muted w-full px-4 py-2" onclick={() => onToggleExpansion(node)}>
<div
class="flex items-center gap-2"
style:padding-inline-start="calc({depth * 6} * var(--spacing))"
>
{#if node instanceof FileNode}
<FileIcon role="presentation" class="size-4" />
{:else}
<FolderIcon role="presentation" class="size-4" />
{/if}

{node.name}

{#if node instanceof FolderNode && node.children.initialized}
<div class="grow text-end">initialized</div>
{/if}
</div>
</button>

{#if node instanceof FolderNode && node.expanded}
{@render treeitems(node.children.current, depth + 1)}
{/if}
{/each}
{/snippet}

<DemoContainer class="h-100">
{@render treeitems(root, 0)}
</DemoContainer>