From 61ab1b05d2cebf7c5a58775e7eb33983c6b6e4f4 Mon Sep 17 00:00:00 2001 From: dangreen Date: Mon, 28 Sep 2026 18:06:45 +0400 Subject: [PATCH 1/6] docs: document the changes since 1.2.0 and add the DevTools page The site, the package READMEs and the DOCS.md of the agent skills now describe the code on main instead of the 1.2.0 release: - tasks attached to the signals they fill: `task($signal, ...)` and `waitTasks($signal)` replace the tasks pool, and `hydratable()` is the whole SSR setting of a query client - `resolved` hands falsy values through and attaches its promise as a task - the `SignalsMap` and `IndexedSignalsMap` classes replace the map functions - the operators return plain values for static operands, `pick` and the `text` tag are new, `concat` is gone - `latest`, `child`, `toHtmlAttribute`, and `escapeHtml` in the SSR adapter example - a DevTools page in the Store section with the development builds and their warnings - the pager labels follow the pages added between the sections, and stale samples in the agera, kida and store READMEs are fixed The benchmark and bundle size numbers come from the comparison page branch, measured on 2026-09-27. --- README.md | 6 + examples/README.md | 18 +- packages/agera/README.md | 6 +- packages/devtools/README.md | 2 +- packages/kida/README.md | 55 +++- packages/ssr/README.md | 6 +- packages/store/README.md | 7 +- skills/nano-kit-platform-web/DOCS.md | 2 +- skills/nano-kit-query/DOCS.md | 101 ++---- skills/nano-kit-react-router/DOCS.md | 12 + skills/nano-kit-react-ssr/DOCS.md | 2 +- skills/nano-kit-react/DOCS.md | 6 +- skills/nano-kit-store/DOCS.md | 293 +++++++++++++++--- website/src/content/docs/examples/cvapp.mdx | 2 +- .../docs/getting-started/agent-skills.mdx | 2 + .../content/docs/getting-started/index.mdx | 49 ++- website/src/content/docs/index.mdx | 2 +- website/src/content/docs/platform/web.mdx | 2 +- website/src/content/docs/query/advanced.mdx | 41 +-- .../src/content/docs/query/core-concepts.mdx | 2 + website/src/content/docs/query/ssr.mdx | 33 +- website/src/content/docs/query/testing.mdx | 25 +- website/src/content/docs/router/advanced.mdx | 12 + website/src/content/docs/ssr/index.mdx | 8 +- website/src/content/docs/ssr/locale.mdx | 2 +- .../content/docs/store-integrations/react.mdx | 6 +- .../docs/store-integrations/svelte.mdx | 2 + website/src/content/docs/store/advanced.mdx | 180 +++++++++-- .../src/content/docs/store/core-concepts.mdx | 13 +- website/src/content/docs/store/devtools.mdx | 71 +++++ website/src/content/docs/store/low-level.mdx | 4 +- website/src/content/docs/store/ssr.mdx | 19 +- website/src/content/docs/store/testing.mdx | 31 +- .../content/docs/tutorial/prepare-for-ssr.mdx | 6 +- website/src/content/docs/tutorial/testing.mdx | 6 +- 35 files changed, 714 insertions(+), 320 deletions(-) create mode 100644 website/src/content/docs/store/devtools.mdx diff --git a/README.md b/README.md index 3043cc5b..db5989f1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A lightweight, modular, and performant state management ecosystem for building m | [`@nano_kit/query`](packages/query#readme) | Data fetching and caching library, built on @nano_kit/store. | [![NPM version][query-npm]][query-npm-url] | | [`@nano_kit/intl`](packages/intl#readme) | Internationalization library, built on @nano_kit/store. | [![NPM version][intl-npm]][intl-npm-url] | | [`@nano_kit/ssr`](packages/ssr#readme) | Base package for server-side rendering capabilities. | [![NPM version][ssr-npm]][ssr-npm-url] | +| [`@nano_kit/devtools`](packages/devtools#readme) | In-page DevTools panel for @nano_kit/store. | [![NPM version][devtools-npm]][devtools-npm-url] | | [`@nano_kit/react`](packages/react#readme) | React integration for @nano_kit/store. | [![NPM version][react-npm]][react-npm-url] | | [`@nano_kit/react-router`](packages/react-router#readme) | React integration for @nano_kit/router. | [![NPM version][react-router-npm]][react-router-npm-url] | | [`@nano_kit/react-ssr`](packages/react-ssr#readme) | React adapter for server-side rendering capabilities. | [![NPM version][react-ssr-npm]][react-ssr-npm-url] | @@ -77,6 +78,11 @@ See the [Agent Skills](https://nano-kit.js.org/getting-started/agent-skills/) pa [ssr-npm]: https://img.shields.io/npm/v/%40nano_kit%2Fssr.svg [ssr-npm-url]: https://npmjs.com/package/@nano_kit/ssr + + +[devtools-npm]: https://img.shields.io/npm/v/%40nano_kit%2Fdevtools.svg +[devtools-npm-url]: https://npmjs.com/package/@nano_kit/devtools + [react-npm]: https://img.shields.io/npm/v/%40nano_kit%2Freact.svg diff --git a/examples/README.md b/examples/README.md index 772caac3..ffbfe52a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,17 +6,17 @@ This document contains bundle size information for various examples in the proje | Stack | Raw Size | Gzipped Size | |-------|----------|--------------| -| Solid + Nano Stores | 36.44 kB | 13.68 kB 🪶 | -| Svelte + Nano Stores | 56.46 kB | 21.69 kB | -| React + Nano Stores | 206.77 kB | 65.90 kB | -| React + nano_kit | 211.32 kB | 67.25 kB | -| React + nano_kit + DI | 212.49 kB | 67.60 kB | -| React + Reatom | 228.16 kB | 73.00 kB | -| React + TanStack Query | 232.63 kB | 72.42 kB 🪨 | +| Solid + Nano Stores | 36.41 kB | 13.92 kB 🪶 | +| Svelte + Nano Stores | 56.08 kB | 21.72 kB | +| React + Nano Stores | 206.71 kB | 65.53 kB | +| React + nano_kit | 211.39 kB | 66.91 kB | +| React + nano_kit + DI | 212.48 kB | 67.28 kB | +| React + Reatom | 228.10 kB | 72.57 kB | +| React + TanStack Query | 232.56 kB | 71.97 kB 🪨 | ## rick-and-morty 🛸 | Stack | Raw Size | Gzipped Size | |-------|----------|--------------| -| React + nano_kit query + nano_kit router | 229.85 kB | 74.48 kB 🪶 | -| React + TanStack Query + TanStack Router | 330.61 kB | 103.44 kB 🪨 | +| React + nano_kit query + nano_kit router | 218.65 kB | 73.29 kB 🪶 | +| React + TanStack Query + TanStack Router | 319.97 kB | 101.70 kB 🪨 | diff --git a/packages/agera/README.md b/packages/agera/README.md index 9b0f961b..7b1c8e4e 100644 --- a/packages/agera/README.md +++ b/packages/agera/README.md @@ -43,10 +43,10 @@ Was created as reactivity system for [Nanoviews](https://github.com/TrigenSoftwa import { signal, computed, effect } from 'agera' const $count = signal(1) -const $doubleCount = computed(() => count() * 2) +const $doubleCount = computed(() => $count() * 2) effect(() => { - console.log(`Count is: ${$count()}`); + console.log(`Count is: ${$count()}`) }) // Console: Count is: 1 console.log($doubleCount()) // 2 @@ -276,8 +276,6 @@ effect(() => { untracked(() => { const b = $b() - resumeTracking() - console.log('Sum:', a + b) }) }) diff --git a/packages/devtools/README.md b/packages/devtools/README.md index b386007b..3eeebe25 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -69,4 +69,4 @@ Open the panel with the pill at the bottom edge of the page, or with Alt+Shift+D ## Documentation -For guides and API reference, visit the [documentation website](https://nano-kit.js.org/devtools). +For guides and API reference, visit the [documentation website](https://nano-kit.js.org/store/devtools/). diff --git a/packages/kida/README.md b/packages/kida/README.md index da244577..758719df 100644 --- a/packages/kida/README.md +++ b/packages/kida/README.md @@ -386,10 +386,10 @@ console.log($atId4()) // John $atId4('Bob') -console.log($atId4()) // { 2: 'Dan', 4: 'Bob', 6: 'Alice' } +console.log($users()) // { 2: 'Dan', 4: 'Bob', 6: 'Alice' } ``` -`atKey` supports dynamic indexes. +`atKey` supports dynamic keys. ```ts import { signal, atKey } from 'kida' @@ -404,7 +404,7 @@ const $user = atKey($users, $id) console.log($user()) // John -$index(6) +$id(6) console.log($user()) // Alice ``` @@ -414,6 +414,21 @@ There are also other methods to work with object maps: - `setKey($object, key, value)` - set value by key to the object signal. - `deleteKey($object, key)` - delete item by key from the object signal. +### `child` + +`child` method creates a signal for a property of a parent signal with a custom writer: `atIndex` and `atKey` are built on it. The writer gets the parent value, the key and the new value, and returns the new parent value. The child is writable when the parent is writable, and read-only otherwise. + +```ts +import { signal, child, assignKey } from 'kida' + +const $user = signal({ name: 'Dan', age: 30 }) +const $name = child($user, 'name', assignKey) + +$name('Alice') + +console.log($user()) // { name: 'Alice', age: 30 } +``` + ## Dependency injection The dependency injection system enables modular architecture and makes testing easier by allowing dependencies to be easily replaced with mocks. It also plays a critical role in SSR scenarios by isolating state between requests. @@ -544,6 +559,30 @@ const $user = signal(null) const $hasUser = boolean($user) ``` +### `latest` + +`latest` method creates a signal with the value of the source that changed last. For example, a search field paired with a URL parameter: typing wins over the URL, navigation wins over what was typed. + +```ts +import { signal, latest } from 'kida' + +const $searchQuery = signal('') // search field +const $searchParam = signal('kida') // URL parameter +const $search = latest($searchQuery, $searchParam) + +console.log($search()) // kida + +$searchQuery('agera') + +console.log($search()) // agera + +$searchParam('nano') + +console.log($search()) // nano +``` + +On the first read, and when several sources changed since the previous read (e.g. inside a `batch`), the last source in the argument list wins. Writing a value equal to the current one is not a change. + ### `$get` `$get` method gets the value from the signal or returns the given value. @@ -611,7 +650,9 @@ const [$position, $error, $pending] = resolved( ) ``` -A falsy source resets all signals to their initial state (`data: undefined`, `error: undefined`, `pending: false`). +A source value that is not a promise, a falsy one included, becomes the result as is. Express an empty state with the type, e.g. `T | null`. + +Each promise from the source is attached as a task, so `waitTasks` of any of the three signals waits until it settles. Like any computed, a signal of the tuple links the source only on its first read: read it before waiting. > [!NOTE] > For remote data fetching with caching, request deduplication, cancellation, refetching, and other advanced features, consider using [`@nano_kit/query`](https://github.com/TrigenSoftware/nano_kit/tree/main/packages/query) instead. @@ -626,6 +667,6 @@ Nano Stores is a great library with wonderful idea of stores with lifecycles. Bu | Benchmark
Throughput avg (ops/s) | Kida / Agera | Alien Signals | Nano Stores | | ------- | --------- | ------- | ------ | -| [signal](../benchmarks/atom.js) | 17 670 321 ± 0.01% | 19 160 348 ± 0.00% | 1 364 731 ± 0.03% | -| [computed](../benchmarks/computed.js) | 1 193 380 ± 0.02% | 1 307 870 ± 0.02% | 229 406 ± 0.14% | -| [effect](../benchmarks/effect.js) | 3 309 252 ± 0.01% | 3 441 332 ± 0.01% | 922 683 ± 0.04% | +| [signal](../benchmarks/atom.js) | 15 048 243 ± 0.01% | 18 967 631 ± 0.00% | 1 389 555 ± 0.02% | +| [computed](../benchmarks/computed.js) | 1 208 888 ± 0.02% | 1 331 229 ± 0.02% | 187 524 ± 0.15% | +| [effect](../benchmarks/effect.js) | 3 257 518 ± 0.01% | 3 463 918 ± 0.01% | 955 623 ± 0.03% | diff --git a/packages/ssr/README.md b/packages/ssr/README.md index c84f13c9..5fe11287 100644 --- a/packages/ssr/README.md +++ b/packages/ssr/README.md @@ -45,7 +45,7 @@ Subclass `Renderer` and implement the abstract `renderToString` method. The cons ```js // src/renderer/index.js -import { Renderer, headDescriptorToHtml, ROOT_ID } from '@nano_kit/ssr/renderer' +import { Renderer, headDescriptorToHtml, escapeHtml, ROOT_ID } from '@nano_kit/ssr/renderer' import { compose } from 'your-framework-router' import { get } from '@nano_kit/store' @@ -64,12 +64,12 @@ export class FrameworkRenderer extends Renderer { else head += headDescriptorToHtml(descriptor) }) - if (title) head = `${title}${head}` + if (title) head = `${escapeHtml(title)}${head}` // render to string using your framework, wrapping with data.context const body = yourFrameworkRenderToString(data.context) - return `${head}
${body}
` + return `${head}
${body}
` } } ``` diff --git a/packages/store/README.md b/packages/store/README.md index 9039da5a..f9b4107f 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -52,10 +52,10 @@ yarn add @nano_kit/store Here is a minimal example demonstrating signals, computed values, and effects in action: ```ts -import { signal, onMount, computed, effect } from '@nano_kit/store' +import { signal, mountable, onMount, computed, effect } from '@nano_kit/store' -/* Create independent atomic stores */ -const $count = signal(1) +/* Create independent mountable atomic store */ +const $count = mountable(signal(1)) /* Mountable: Run logic only when store has listeners */ onMount($count, () => { @@ -76,6 +76,7 @@ const unsub = effect(() => { console.log(`Count: ${$count()}, Double: ${$double()}`) }) // Output: Count: 1, Double: 2 +// Output: Mounted: Store is active /* Update triggers granular propagation */ $count(2) diff --git a/skills/nano-kit-platform-web/DOCS.md b/skills/nano-kit-platform-web/DOCS.md index 6aaab4b3..516936d2 100644 --- a/skills/nano-kit-platform-web/DOCS.md +++ b/skills/nano-kit-platform-web/DOCS.md @@ -327,7 +327,7 @@ const context = [ ] ``` -`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. Empty headers fall back to `{ language: 'en', languages: ['en'] }`. +`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. An empty or missing header (`null` or `undefined`) falls back to `{ language: 'en', languages: ['en'] }`. ## User Agent diff --git a/skills/nano-kit-query/DOCS.md b/skills/nano-kit-query/DOCS.md index c25fcbee..07fd2b35 100644 --- a/skills/nano-kit-query/DOCS.md +++ b/skills/nano-kit-query/DOCS.md @@ -333,6 +333,8 @@ SearchKey('react', 2) This allows different page requests to share the same cache entry. +The filter is also the place to drop or map a parameter that does not serialize, such as a function or a symbol: it would become `null` in the key, and the development build warns about it. + ### Using Cache Keys with Client Methods #### Reading and Writing Data @@ -672,22 +674,24 @@ You do not need this for ordinary in-memory browser cache usage. While the app i Use it when serialized cache entries contain values that need custom encoding, such as `Date`, `Map`, `Set`, class instances, or encrypted/compact payloads. -`hydratable()`, `ssr()`, and [`persistence()`](#persistence) use the codec when they dehydrate data, hydrate it back, write it to persistent storage, or read it back. +`hydratable()` and [`persistence()`](#persistence) use the codec when they dehydrate data, hydrate it back, write it to persistent storage, or read it back. ```ts import type { Codec } from '@nano_kit/store' import { parse, stringify } from 'devalue' -import { client, codec, ssr } from '@nano_kit/query' +import { client, codec, hydratable } from '@nano_kit/query' const DevalueCodec: Codec = { encode: stringify, decode: value => value === null ? null : parse(value) } -const { query } = client( - codec(DevalueCodec), - ssr() -) +export function Client$() { + return client( + codec(DevalueCodec), + hydratable() + ) +} ``` With a codec like this, data containing values such as `Date`, `Map`, or `Set` can be dehydrated on the server and restored on the client without losing those types. @@ -758,6 +762,8 @@ keys(invalidate) This is useful after broad application changes, such as sign in, sign out, locale changes, or any mutation that can affect several query domains at once. +The name of a builder is its cache shard: two builders with one name share one cache, and the development build warns when a name is registered twice. + ## Cache Mutation Helpers For comfortable cache mutations, use helpers that fit naturally into `$data` reducers. They are especially handy for optimistic updates. @@ -1224,31 +1230,6 @@ The `capture` helper stores each entity in the shared entity cache and returns a **When to use:** Complex applications where the same data (e.g., a "User" or "Product") appears in multiple places or lists and needs to stay synchronized. -### `tasks` - -Integrates with `@nano_kit/store`'s task tracking system. This is mainly used for server-side rendering (SSR) to wait for all data fetches to complete before rendering the HTML. - -```ts -import { tasksRunner, waitTasks } from '@nano_kit/store' -import { client, tasks } from '@nano_kit/query' - -const tasksPool = new Set() -const runTask = tasksRunner(tasksPool) - -const { query } = client( - tasks(runTask) -) - -/* ... application runs ... */ - -/* Wait for all queries to finish */ -await waitTasks(tasksPool) -``` - -Without arguments, `tasks()` reads `TasksRunner$` from the current injection context. Outside DI, pass the runner explicitly. - -**When to use:** SSR setups where the server should send a fully populated page to the client. - --- # SSR @@ -1257,54 +1238,33 @@ Learn how to use @nano_kit/query for server-side rendering (SSR). Server-side rendering in `@nano_kit/query` is built around two pieces: task tracking and cache dehydration. -Queries started during server render should be awaited before HTML is returned, and the resulting cache state should be transferred to the client for hydration. +Queries started during server render should be awaited before HTML is returned, and the resulting cache state should be transferred to the client for hydration. Task tracking is built in: every query attaches its request as a task to its data signal, so there is nothing to set up for it. Cache dehydration is the [`hydratable()`](#hydratable) setting. ## `hydratable` -Use `hydratable()` when query cache should be dehydrated on the server and rehydrated on the client. +Use `hydratable()` when query cache should be dehydrated on the server and rehydrated on the client. It is the whole SSR setting of a query client. Without arguments, it reads hydration dependencies from the current injection context. Outside DI, pass them explicitly. -In a manual setup, it is typically paired with `tasks()`, which works the same way: without arguments it reads `TasksRunner$` from the current injection context, and outside DI you can pass the runner explicitly. - If the client uses `codec(...)`, `hydratable()` encodes cached data before dehydration and decodes it during hydration. ```ts -import { client, tasks, hydratable } from '@nano_kit/query' - -const { query } = client( - tasks(), - hydratable() -) -``` - -Use this when you want to customize task handling or hydration setup independently. - -## `ssr` - -`ssr()` is a convenience setting that combines `tasks()` and `hydratable()`. - -Inside an SSR injection context, it reads the task runner and hydration dependencies automatically. If you need explicit control instead, use `tasks(...)` and `hydratable(...)` directly. - -Because `ssr()` uses `hydratable()` internally, it also respects the current `codec(...)` setting. - -```ts -import { client, dedupeTime, ssr } from '@nano_kit/query' +import { client, dedupeTime, hydratable } from '@nano_kit/query' const DEDUPE_TIME = 300_000 // 5 minutes export function Client$() { return client( dedupeTime(DEDUPE_TIME), - ssr() + hydratable() ) } ``` This enables the usual SSR flow: -1. Queries started during server render are tracked as tasks. -2. The renderer waits for those tasks to finish. +1. Queries started during server render attach their requests as tasks to their data signals. +2. The renderer waits for the tasks of the signals the page returns from `Stores$`, and of everything they depend on. 3. Query cache state is dehydrated into the hydration payload. 4. The client rehydrates the cache and reuses the prefetched data. @@ -1324,28 +1284,23 @@ Please refer to the Store Testing documentation for general guidelines on: ## Waiting for Queries -Tests often need to wait for asynchronous data fetching to resolve. The `tasks` extension provides a reliable way to await all pending operations in your query client, ensuring your assertions run only after data is loaded. +Tests often need to wait for asynchronous data fetching to resolve. Every query attaches its request as a task to its data signal, so `waitTasks` of that signal awaits the request with no extra setup, ensuring your assertions run only after data is loaded. ```ts import { describe, it, expect, vi } from 'vitest' -import { tasksRunner, waitTasks, start } from '@nano_kit/store' -import { client, tasks, queryKey } from '@nano_kit/query' +import { waitTasks, start } from '@nano_kit/store' +import { client, queryKey } from '@nano_kit/query' const TestKey = queryKey('test') it('should fetch and update state', async () => { - /* 1. Create a pool to track active tasks */ - const tasksPool = new Set() - - /* 2. Configure client with tasks extension */ - const { query } = client( - tasks(tasksRunner(tasksPool)) - ) + /* 1. Create a client, no task setup is needed */ + const { query } = client() /* Mock fetcher */ const fetcher = vi.fn().mockResolvedValue('success') - /* 3. Start the query */ + /* 2. Start the query */ const [$data, $error, $loading] = query(TestKey, [], fetcher) /* Mount the signal to trigger the fetch */ @@ -1353,13 +1308,15 @@ it('should fetch and update state', async () => { expect($loading()).toBe(true) - /* 4. Wait for all fetch tasks to complete */ - await waitTasks(tasksPool) + /* 3. Wait for the request of $data to complete */ + await waitTasks($data) - /* 5. Assert final state */ + /* 4. Assert final state */ expect($loading()).toBe(false) expect($data()).toBe('success') stop() }) ``` + +`waitTasks` of any signal computed from `$data` waits for the request as well. Infinite queries and operations attach their requests to `$data` too. A mutation attaches each request to all of its signals, so waiting for any of them works, as does awaiting the promise returned by `mutate`. diff --git a/skills/nano-kit-react-router/DOCS.md b/skills/nano-kit-react-router/DOCS.md index 819347ed..2329958c 100644 --- a/skills/nano-kit-react-router/DOCS.md +++ b/skills/nano-kit-react-router/DOCS.md @@ -611,6 +611,18 @@ export function Head$() { } ``` +### `toHtmlAttribute` + +Descriptor props are camelCase, like `httpEquiv` or `crossOrigin`. `toHtmlAttribute` maps a prop name to its HTML attribute name, the same way `syncHead` and the SSR renderer do. Use it when a custom adapter turns descriptor props into attributes itself. + +```ts +import { toHtmlAttribute } from '@nano_kit/router' + +toHtmlAttribute('httpEquiv') // 'http-equiv' +toHtmlAttribute('crossOrigin') // 'crossorigin' +toHtmlAttribute('content') // 'content' +``` + ## Scroll Management Since scroll restoration and behavior can vary significantly between applications, the library provides a set of "Do It Yourself" (DIY) utilities instead of a one-size-fits-all solution. You can combine these tools to implement the exact scrolling behavior your app needs. diff --git a/skills/nano-kit-react-ssr/DOCS.md b/skills/nano-kit-react-ssr/DOCS.md index c34ae94f..2e928c2a 100644 --- a/skills/nano-kit-react-ssr/DOCS.md +++ b/skills/nano-kit-react-ssr/DOCS.md @@ -580,4 +580,4 @@ browserLocale(context.get(Locales$), ['en', 'ru-RU'], 'en') // 'ru-RU' ``` -`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. Empty headers fall back to `{ language: 'en', languages: ['en'] }`. +`parseLocales` sorts languages by `q` quality and keeps declaration order when qualities are equal. An empty or missing header (`null` or `undefined`) falls back to `{ language: 'en', languages: ['en'] }`. diff --git a/skills/nano-kit-react/DOCS.md b/skills/nano-kit-react/DOCS.md index 2ddb146f..f2ca3659 100644 --- a/skills/nano-kit-react/DOCS.md +++ b/skills/nano-kit-react/DOCS.md @@ -152,7 +152,7 @@ import { User$ } from './stores' function Stores$() { const { $user } = inject(User$) - /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + /* Will execute store logic, wait for the async tasks of these stores to complete, and extract the dehydrated snapshot */ return [$user] } @@ -179,7 +179,7 @@ import { User$ } from './stores' function Stores$() { const { $user } = inject(User$) - /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + /* Will execute store logic, wait for the async tasks of these stores to complete, and extract the dehydrated snapshot */ return [$user] } @@ -218,7 +218,7 @@ import { User$ } from './stores' function Stores$() { const { $user } = inject(User$) - /* Will execute store logic, wait for all async tasks to complete, and extract the dehydrated snapshot */ + /* Will execute store logic, wait for the async tasks of these stores to complete, and extract the dehydrated snapshot */ return [$user] } diff --git a/skills/nano-kit-store/DOCS.md b/skills/nano-kit-store/DOCS.md index 4aedc05a..505599cf 100644 --- a/skills/nano-kit-store/DOCS.md +++ b/skills/nano-kit-store/DOCS.md @@ -364,15 +364,15 @@ The Dependency Injection system enables modular architecture and makes testing e Use an injectable function or class with `inject` to retrieve dependencies. For example, in React, use `InjectionContextProvider` to create a context and `useInject` to access dependencies. ```tsx -import { inject, signal, mountable, onMountEffect, action, effect, TasksRunner$ } from '@nano_kit/store' +import { signal, mountable, onMountEffect, action, task } from '@nano_kit/store' import { InjectionContextProvider, useInject, useSignal } from '@nano_kit/react' /* Injectable function that defines a user store */ function User$() { - const task = inject(TasksRunner$) const $userId = signal(null) const $user = mountable(signal(null)) - const fetchUser = action((id) => task(async () => { + /* Attach the request to $user, so SSR and tests can wait for it */ + const fetchUser = action((id) => task($user, async () => { if (typeof id !== 'number') { $user(null) return @@ -499,15 +499,16 @@ const value = $get($count) Injectable tokens that start with an uppercase letter and end with `$` are Dependency Injection tokens. ```ts -import { Injectable$, TasksRunner$, inject } from '@nano_kit/store' +import { Injectable$, inject } from '@nano_kit/store' +import { CookieStore$ } from '@nano_kit/platform-web' function User$() { - const task = inject(TasksRunner$) + const cookieStore = inject(CookieStore$) /* ... */ } class UserService$ extends Injectable$ { - task = inject(TasksRunner$) + cookieStore = inject(CookieStore$) } ``` @@ -779,33 +780,95 @@ Additional object helpers: ### Signals Map -`SignalsMap` is a reactive `Map` where each entry's value is a signal. +`SignalsMap` is a `Map` whose values are reactive: every value lives in a signal of its own, and the signals never leave the map. It is a class created with `new`, where `get` reads a value without tracking, `$get` reads it and tracks it, and `set`, `delete`, `clear`, `has`, `keys` and `size` work as they do on a `Map`. ```ts -import { type SignalsMap, $getMapKey, setMapKey, deleteMapKey, clearMap } from '@nano_kit/store' +import { SignalsMap, effect } from '@nano_kit/store' + +interface User { + id: number + name: string +} -const userMap: SignalsMap = new Map() +const users = new SignalsMap() /* Set entry */ -setMapKey(userMap, 1, { name: 'Dan', age: 30 }) +users.set(1, { id: 1, name: 'Dan' }) /* Get entry reactively */ effect(() => { - console.log('User:', $getMapKey(userMap, 1)) + console.log('User:', users.$get(1)) }) +/* Update entry with a reducer, as with a writable signal */ +users.set(1, user => user && { ...user, name: 'Alice' }) + +/* Get entry without tracking */ +users.get(1) + /* Delete entry */ -deleteMapKey(userMap, 1) +users.delete(1) /* Clear all */ -clearMap(userMap) +users.clear() +``` + +A reader of a key that is in the map runs again only when its value changes, not when other keys are set. A reader of a missing key runs again when a key is added or removed, so it gets the value as soon as the key is set. + +`IndexedSignalsMap` adds `$index`, a computed list of the keys: it changes when a key is added or removed, not when a value is set. `$index` also stands for the whole map in the lifecycle: mark it `mountable` right after the map is created, and every reader of the map mounts it, the readers of `$get` included. + +```ts +import { IndexedSignalsMap, mountable, onMount, effect } from '@nano_kit/store' + +const users = new IndexedSignalsMap() + +/* Mark the index mountable before anything reads the map */ +onMount(mountable(users.$index), () => { + /* Runs when the first reader of the map appears */ + const socket = new WebSocket('wss://example.com/users') + + socket.onmessage = (event) => { + const user: User = JSON.parse(event.data) + + users.set(user.id, user) + } + + return () => socket.close() +}) + +/* Runs again when a key is added or removed, not when a value is set */ +effect(() => { + console.log('User ids:', users.$index()) +}) + +/* Runs again when user 1 changes, and keeps the map mounted as well */ +effect(() => { + console.log('User 1:', users.$get(1)) +}) ``` ## Functional Operators -Functional operators (fops) are pure functions that create accessors from other accessors or values. Unlike `computed`, they don't memoize results — they recalculate on every access. For memoization, wrap them in `computed`. +Functional operators (fops) are pure functions that combine accessors and plain values. Every operator checks its operands: with an accessor among them the result is an accessor, and with none it is the plain value, so a binding made of static values costs no effect. Unlike `computed`, a returned accessor does not memoize: it recalculates on every access. For memoization, wrap it in `computed`. + +```ts +import { type Signalish, signal, is } from '@nano_kit/store' + +const $variant = signal('primary') + +/* Accessor: an accessor is among the operands */ +const $isPrimary = is($variant, 'primary') -These operators accept either static values or accessors, making them flexible for building derived state without immediate caching. +/* boolean: every operand is static */ +const isPrimary = is('primary', 'primary') + +/* Signalish: the operand may turn out either way */ +function isPrimaryVariant(variant: Signalish) { + return is(variant, 'primary') +} +``` + +The `Fop` type describes the result of an operator: an accessor when an operand is an accessor, the plain value when none can be, and `Signalish` of the value when an operand may be either. ### Logical Operations @@ -870,6 +933,48 @@ const $greeting = when( console.log($greeting()) /* "Hello, Alice" */ ``` +### Lookup + +**`pick`** looks a value up in a collection by key. The collection is an object or an array, and both it and the key can be static or accessors. An empty key (`null` or `undefined`) picks `undefined`, which suits an optional prop. + +```ts +import { signal, pick } from '@nano_kit/store' + +const styles = { + primary: 'button_primary', + secondary: 'button_secondary' +} +const $variant = signal<'primary' | 'secondary'>('primary') +const $className = pick(styles, $variant) + +console.log($className()) /* "button_primary" */ + +$variant('secondary') +console.log($className()) /* "button_secondary" */ + +/* Static operands give the value itself */ +pick(styles, 'primary') /* "button_primary" */ +``` + +### Text + +**`text`** is a template tag that builds a string from a template literal. The values in it are strings, numbers, booleans or bigints, static or accessors, and an empty value (`null` or `undefined`) adds nothing. + +```ts +import { signal, text } from '@nano_kit/store' + +const $name = signal('Dan') +const $greeting = text`Hello, ${$name}!` + +console.log($greeting()) /* "Hello, Dan!" */ + +$name('Alice') +console.log($greeting()) /* "Hello, Alice!" */ + +/* Static values give the string itself */ +text`Step ${1} of ${3}` /* "Step 1 of 3" */ +``` + ### Memoization Since fops don't cache results, wrap them in `computed` for expensive operations: @@ -927,7 +1032,7 @@ The library provides a set of utility functions for common tasks like rate limit ### Async State -**`resolved`** unwraps a promise accessor into a `[$result, $error, $pending]` tuple of signals. Stale data is preserved while a new promise is pending, and falsy values reset the state. +**`resolved`** unwraps a promise accessor into a `[$result, $error, $pending]` tuple of signals. Stale data is preserved while a new promise is pending. ```ts import { signal, computed, resolved } from '@nano_kit/store' @@ -944,7 +1049,21 @@ const [$position, $error, $pending] = resolved( ) ``` -A falsy source resets all signals to their initial state (`result: undefined`, `error: undefined`, `pending: false`). +A source value that is not a promise becomes the result as it is, a falsy one included, so an empty state is expressed by the type itself, for example `User | null`. + +```ts +import { signal, resolved } from '@nano_kit/store' + +const $userId = signal(null) +const [$user] = resolved(() => { + const id = $userId() + + /* The result is null until there is a user to fetch */ + return id === null ? null : fetchUser(id) +}) +``` + +Each promise from the source is attached as a task, so `waitTasks` of any of the three signals waits until it settles. A signal of the tuple links the source on its first read, so read or mount it before waiting. > Note: For remote data fetching with caching, request deduplication, cancellation, refetching, and other advanced features, consider using `@nano_kit/query` instead. @@ -1060,6 +1179,30 @@ $count(2) /* Changed from 1 to 2 */ $count(3) /* Changed from 2 to 3 */ ``` +### Latest Value + +**`latest`** merges several sources into one computed that carries the value of the source that changed last. It fits a local cell paired with a read-only source: typing wins over the URL, and navigation wins over what was typed. Where `previous` looks back through a single source, `latest` picks the freshest value across several. + +```ts +import { signal, latest, effect } from '@nano_kit/store' + +/* Stands for a read-only source, such as a search param of the URL */ +const $searchParam = signal('shoes') +/* The local cell the search field writes */ +const $searchQuery = signal('') +const $search = latest($searchQuery, $searchParam) + +effect(() => { + console.log('Search:', $search()) +}) +/* Search: shoes */ + +$searchQuery('boots') /* Search: boots */ +$searchParam('hats') /* Search: hats */ +``` + +On the first read the last source wins, and when several sources changed since the previous read, as with writes inside one `batch`, the last of them in argument order wins. Only a new value counts as a change: writing a value equal to the current one leaves the result as it is. + ### Computed Properties **`length`** creates a computed for the `length` property of arrays or strings. @@ -1086,22 +1229,6 @@ $user({ name: 'Dan' }) console.log($hasUser()) /* true */ ``` -**`concat`** concatenates multiple values or accessors into a string. - -```ts -import { signal, concat, effect } from '@nano_kit/store' - -const $firstName = signal('John') -const $lastName = signal('Doe') -const $fullName = concat($firstName, ' ', $lastName) - -effect(() => { - console.log($fullName()) -}) - -$firstName('Jane') /* Jane Doe */ -``` - ### Type Checking **`isFunction`** checks if a value is a function. @@ -1327,17 +1454,16 @@ function User$() { ## Working with Tasks -SSR requires waiting for all async operations to complete before dehydrating. Use `TasksRunner$` to create a task runner that automatically tracks async operations in `TasksPool$`. +SSR requires waiting for all async operations to complete before dehydrating. Use `task` to attach an async operation to the signal it fills: until the operation settles, `waitTasks` of that signal, and of every signal that depends on it, waits for it. ```ts -import { inject, signal, hydratable, mountable, onMountEffect, action, TasksRunner$ } from '@nano_kit/store' +import { signal, hydratable, mountable, onMountEffect, action, task } from '@nano_kit/store' function User$() { - const task = inject(TasksRunner$) const $userId = hydratable('userId', signal(null)) const $user = hydratable('user', mountable(signal(null))) - const fetchUser = action((id) => task(async () => { + const fetchUser = action((id) => task($user, async () => { if (typeof id !== 'number') { $user(null) return @@ -1357,7 +1483,9 @@ function User$() { } ``` -The `task` function wraps async operations and adds them to the tasks pool. The `dehydrate` function waits for all tasks in the pool to complete before extracting signal values. +`task` takes the signal the operation fills (or an array of signals) and a promise or a function that returns one, and returns that promise. `waitTasks($signal)` walks the dependency graph of the signal and waits, wave by wave, until neither the signal nor anything it depends on has tasks left, so a task started by another task and a dependency read only after a value settles are awaited too. + +There is no pool to create or provide: tasks live on the signals they fill, so `dehydrate` waits for exactly the stores it starts, and a test waits for exactly the signal it checks. Queries of `@nano_kit/query` and `resolved` attach their tasks by themselves, so stores built on them need no `task` call. ## Manual Hydration Flow @@ -1365,7 +1493,7 @@ The `task` function wraps async operations and adds them to the tasks pool. The ### Server-Side Dehydration -On the server, use `dehydrate` to execute your store logic, wait for all async tasks to complete, and extract the dehydrated key-value pairs. +On the server, use `dehydrate` to execute your store logic, wait for its async tasks to complete, and extract the dehydrated key-value pairs. ```ts import { dehydrate, inject } from '@nano_kit/store' @@ -1386,10 +1514,10 @@ const dehydrated = await dehydrate(() => { The `dehydrate` function: -1. Creates an injection context with a task pool, or uses the context passed as the second argument +1. Creates an injection context, or uses the context passed as the second argument 2. Runs your store factories within that context 3. Starts effects to trigger `onMount` callbacks (which start async tasks) -4. Waits for all tasks in `TasksPool$` to complete +4. Waits for the tasks of the returned signals and of every signal they depend on 5. Collects all signals marked with `hydratable` 6. Returns the dehydrated key-value pairs @@ -1434,6 +1562,8 @@ function UserProfile() { `StaticHydrator` is a one-shot hydrator: it applies values from the initial dehydrated snapshot and discards them after use. For streaming SSR where chunks of dehydrated data arrive after the initial render, use `ActiveHydrator` instead — it exposes a `push(dehydrated)` method to feed additional data reactively. +In the development build, `StaticHydrator` warns about an id that has no value in the snapshot: the signal was not dehydrated on the server, or the same id is hydrated twice. + --- # Testing @@ -1521,22 +1651,35 @@ expect(mounted).toHaveBeenCalledTimes(1) ## Waiting for Async Operations -Tasks are useful not only for SSR but also in tests to wait for async operations inside effects. Use tasks pool with `waitTasks` to ensure all async work completes before assertions. +Tasks are useful not only for SSR but also in tests to wait for async operations inside effects. Pass the signal under test to `waitTasks` to ensure its async work completes before assertions. ```ts -import { type TasksPool, waitTasks, tasksRunner } from '@nano_kit/store' -import { client, tasks } from '@nano_kit/query' +import { expect, vi } from 'vitest' +import { mountable, signal, computed, onMount, start, task, waitTasks } from '@nano_kit/store' + +const fetchName = vi.fn().mockResolvedValue('John') +const $name = mountable(signal(null)) +const $greeting = computed(() => `Hello, ${$name() ?? 'guest'}`) + +/* The store attaches its request to the signal it fills */ +onMount($name, () => { + void task($name, async () => { + $name(await fetchName()) + }) +}) -const tasksPool: TasksPool = new Set() -const { query } = client(tasks(tasksRunner(tasksPool))) +/* Mount $greeting and the $name it reads */ +const stop = start($greeting) -/* ... trigger async operations ... */ +/* Wait for the tasks of $greeting and of every signal it depends on */ +await waitTasks($greeting) -/* Wait for all tasks to complete */ -await waitTasks(tasksPool) +expect($greeting()).toBe('Hello, John') + +stop() ``` -The `waitTasks` function waits for all tasks in the pool to complete, including tasks that spawn new tasks. For simpler cases, use `waitCurrentTasks` to wait only for currently running tasks. +The `waitTasks` function waits until neither the signal nor anything it depends on has tasks left, including tasks that spawn new tasks. It follows the links the graph already has, so start the signal before waiting: a computed links its dependencies on its first read. Queries, mutations and `resolved` attach their tasks by themselves. `waitTasks` resolves even when a task fails, so assert the error state after it. ## Mocking Dependencies @@ -1562,3 +1705,53 @@ const context = new InjectionContext([ /* Inject store with mocked context */ const { $episodes } = inject(Episodes$, context) ``` + +--- + +# DevTools + +Learn how to inspect the stores of a running app with @nano_kit/devtools. + +`@nano_kit/devtools` is an in-page DevTools panel for @nano_kit/store: the reactive graph of your app, its values, its lifecycle and a log of every transaction, right on the page. + +- **On the page**. A panel over your app in its own shadow root: no browser extension, no bundler plugin, and a strict Content Security Policy does not stop it. +- **Free in production**. The guarded call folds away with the whole package, and the core reports to the panel in its [development build](#development-builds) alone. +- **Named for you**. Every signal gets a readable name, the place it was created and its owner, the store factory, the component or the file, straight from the call stack. +- **Transactions, not events**. The log groups what happened by flush, with the time of every run and the slowest one called out. +- **Live values**. Pick any node to see its value as a tree, what it reads, what reads it, and its last lines in the log. + +## Installation + +Install the package as a dev dependency using your favorite package manager: + +```bash +pnpm add -D @nano_kit/devtools +``` + +## Quick Start + +Call `devtools()` once, behind the development flag of your bundler, and import it before the modules that create your stores, so their signals are named from the start: + +```ts title="src/devtools.ts" +import { devtools } from '@nano_kit/devtools' + +if (import.meta.env.DEV) { + devtools() +} +``` + +```ts title="src/main.ts" +/* First of all, before the modules that create stores */ +import './devtools' +import { App } from './App' +``` + +Open the panel with the pill at the bottom edge of the page, or with Alt+Shift+D / ⌥⇧D: + +- **Signals**. Every signal, computed and child signal, grouped by the factory, component or file it was created in, with its value, state and links. +- **Log**. Every transaction: a write and all it caused, down to the effects that ran and how long each took. +- **Inspector**. The node you picked: its value, what it reads and what reads it, and its recent history. + +## Development Builds + +`@nano_kit/store`, `@nano_kit/query`, `@nano_kit/router`, `@nano_kit/intl` and the core under them, `kida` and `agera`, ship two builds: `dist/index.development.js` and `dist/index.production.js`. Their `exports` pick one through the `development` and `production` conditions, and `default` is the production build. Bundlers that resolve the `development` condition during development, Vite among them, serve the development build with no setup, while production bundles get the production build. diff --git a/website/src/content/docs/examples/cvapp.mdx b/website/src/content/docs/examples/cvapp.mdx index 5089d33a..1d860869 100644 --- a/website/src/content/docs/examples/cvapp.mdx +++ b/website/src/content/docs/examples/cvapp.mdx @@ -10,7 +10,7 @@ This example shows a CV editor built with `@nano_kit/store`, `@nano_kit/query`, It is a compact reference for routed application screens, cached async data, derived state, and React components that subscribe to signals.