@vielzeug/codex 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Vielzeug — Full Documentation
2
2
 
3
- > Complete documentation for all 30 Vielzeug packages. Version: 1.0.2
3
+ > Complete documentation for all 31 Vielzeug packages. Version: 1.0.3
4
4
 
5
5
  ---
6
6
 
@@ -1211,6 +1211,569 @@ const price = currency({ amount: 123456n, currency: 'USD' }); // $1,234.56
1211
1211
  - Type checking utilities (id: `typed-is`)
1212
1212
 
1213
1213
 
1214
+ ---
1215
+
1216
+ ## @vielzeug/assay
1217
+
1218
+ **Category:** testing
1219
+ **Keywords:** testing, dom, events, queries, waitfor, custom-elements, jsdom
1220
+ **Key exports:** within, query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText, queryInShadow, queryAllInShadow, queryPart, getSlotted, fire (+7 more)
1221
+ **Related:** ore, refine
1222
+
1223
+ ### Overview
1224
+
1225
+ ## Why Assay?
1226
+
1227
+ Testing DOM-level code (custom elements, vanilla event handlers, framework-rendered output) usually means hand-rolling `dispatchEvent` boilerplate and ad-hoc polling loops, or pulling in a full testing-library dependency tied to one framework's rendering model. Assay extracts just the generic, framework-agnostic pieces — scoped queries, event dispatch, async waiting — as a standalone, zero-dependency package.
1228
+
1229
+ ```ts
1230
+ // Before — hand-rolled event dispatch and polling in every test file
1231
+ const btn = panel.querySelector('button.submit')!;
1232
+
1233
+ btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1234
+
1235
+ const deadline = Date.now() + 1000;
1236
+ while (Date.now() setTimeout(r, 50));
1237
+ }
1238
+
1239
+ // After
1240
+ import { fire, waitFor, within } from '@vielzeug/assay';
1241
+
1242
+ const { query, queryByText } = within(panel);
1243
+
1244
+ fire.click(query('button.submit')!);
1245
+ await waitFor(() => queryByText('Saved') !== null);
1246
+ ```
1247
+
1248
+ | Feature | Assay | @testing-library/dom |
1249
+ | ----------------------- | ------------------------------------------------ | ---------------------------------------- |
1250
+ | Bundle size | | ~15 kB |
1251
+ | Framework-agnostic | | |
1252
+ | Scoped queries | `within()` | |
1253
+ | Low-level event dispatch| `fire.*` | Partial (`fireEvent`) |
1254
+ | Deterministic `waitFor` | | |
1255
+ | Zero dependencies | | |
1256
+
1257
+ **Use Assay when** you're testing custom elements or vanilla DOM code and want scoped queries, synchronous event dispatch, and async waiting without adopting a framework-specific testing library.
1258
+
1259
+ **Consider alternatives when** you're already standardized on `@testing-library/dom` (or a framework-specific wrapper around it) and don't need to avoid that dependency.
1260
+
1261
+ ## Installation
1262
+
1263
+ Assay is a testing dependency — install it alongside your test runner.
1264
+
1265
+ ```sh [pnpm]
1266
+ pnpm add -D @vielzeug/assay
1267
+ ```
1268
+
1269
+ ```sh [npm]
1270
+ npm install -D @vielzeug/assay
1271
+ ```
1272
+
1273
+ ```sh [yarn]
1274
+ yarn add -D @vielzeug/assay
1275
+ ```
1276
+
1277
+ ## Quick Start
1278
+
1279
+ ```ts
1280
+ import { fire, waitFor, within } from '@vielzeug/assay';
1281
+
1282
+ const panel = document.querySelector('.panel')!;
1283
+ const { query, queryByText } = within(panel);
1284
+
1285
+ fire.click(query('button.submit')!);
1286
+
1287
+ await waitFor(() => queryByText('Saved') !== null);
1288
+ ```
1289
+
1290
+ ## Features
1291
+
1292
+ - `within(element)` — scoped `query`/`queryAll`/`queryByText`/`queryAllByText`/`queryByTestId`/`queryAllByTestId` for any element or shadow root, each also available as a free function (`query(root, selector)`, etc.) for when you already have a root.
1293
+ - `queryInShadow`/`queryAllInShadow`/`queryPart` — shadow-DOM-aware queries that return `null`/`[]` instead of throwing when the host has no shadow root.
1294
+ - `getSlotted(host, slotName?)` — light-DOM children assigned to a named slot, or every slotted child.
1295
+ - `fire.*` — low-level synchronous DOM event dispatchers (`click`, `input`, `keyDown`, `pointerDown`/`pointerMove`/`pointerCancel`, `custom`, and more), each with sensible `bubbles`/`cancelable` defaults and a consistent `boolean` return value — no exceptions.
1296
+ - `waitFor(fn, options?)` — polls until a callback returns truthy or a bare `expect()` call doesn't throw; always rejects with `AssayTimeoutError` on timeout, with the original failure preserved as `.cause`.
1297
+ - `waitForEvent(element, name, timeout?)` — resolves with the next matching event, or rejects with `AssayTimeoutError`.
1298
+ - `nextTick()`/`wait(ms?)` — microtask and macrotask timing helpers for reactive updates and debounced code.
1299
+ - `AssayError` / `AssayTimeoutError` — a single error hierarchy for every timeout this package raises.
1300
+ - No DOM-framework coupling — works with vanilla custom elements, `@vielzeug/ore` components, or any other DOM output.
1301
+
1302
+ ## Documentation
1303
+
1304
+ - [Usage Guide](./usage.md)
1305
+ - [API Reference](./api.md)
1306
+
1307
+ ## See Also
1308
+
1309
+ - [Ore](/ore/) — web-component authoring library whose `@vielzeug/ore/testing` sub-path re-exports Assay's query/event/wait primitives
1310
+ - [Refine](/refine/) — accessible component library built on Ore, testable with the same Assay primitives
1311
+
1312
+ ### API Reference
1313
+
1314
+ ## API Overview
1315
+
1316
+ | Symbol | Purpose | Execution mode | Common gotcha |
1317
+ | -------------------- | ----------------------------------------------------- | -------------- | -------------- |
1318
+ | `within(element)` | Scoped query helpers for one element/subtree | Sync | Returns a fresh `QueryScope` per call — cheap, but don't cache it across DOM mutations you care about |
1319
+ | `query()` / `queryAll()` | Free-function equivalents of `within(root).query`/`.queryAll` | Sync | Use these when you already have a root and don't need the rest of `QueryScope` |
1320
+ | `queryByTestId()` / `queryAllByTestId()` | Match a `data-testid` attribute | Sync | Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` |
1321
+ | `queryByText()` | First element matching trimmed text content | Sync | Matches exact trimmed text, not substrings |
1322
+ | `queryAllByText()` | Every element matching trimmed text content | Sync | Same exact-match caveat as `queryByText()` |
1323
+ | `queryInShadow()` / `queryAllInShadow()` | Query inside a host's shadow root | Sync | Returns `null`/`[]` (not a throw) when the host has no shadow root |
1324
+ | `queryPart()` | Query a shadow-DOM element by its `part` attribute | Sync | Shorthand for `queryInShadow(host, '[part="x"]')` |
1325
+ | `getSlotted()` | Light-DOM children assigned to a slot | Sync | Only direct children (`:scope >`) — doesn't recurse into further-nested slots |
1326
+ | `fire.*` | Dispatch a DOM event synchronously | Sync | Doesn't wait for anything — pair with `waitFor()`/`await` for async reactions |
1327
+ | `createPointerEvent()` | Build a `PointerEvent`, falling back to `MouseEvent` | Sync | Only needed if you're constructing an event by hand instead of using `fire.pointer*` |
1328
+ | `waitFor()` | Poll until a callback returns truthy or resolves | Async | Always rejects with `AssayTimeoutError` on timeout — the original failure is `.cause`, never the thrown error's own type |
1329
+ | `waitForEvent()` | Resolve on the next matching event | Async | Rejects with `AssayTimeoutError`, not a plain `Error`, on timeout |
1330
+ | `nextTick()` | Resolve after one microtask tick | Async | Doesn't wait for `setTimeout`-scheduled work — use `wait()` for that |
1331
+ | `wait()` | Resolve after a fixed millisecond delay | Async | A fixed delay, not a condition — prefer `waitFor()` when you can express a condition instead |
1332
+
1333
+ ## Package Entry Point
1334
+
1335
+ | Import | Purpose |
1336
+ | -------------------- | ------------------------------------------------------------------- |
1337
+ | `@vielzeug/assay` | The entire public API — queries, event dispatch, async waiting |
1338
+
1339
+ ## Query Helpers
1340
+
1341
+ ### `within(element)`
1342
+
1343
+ Creates query helpers scoped to a single element or shadow root.
1344
+
1345
+ **Parameters**
1346
+
1347
+ | Name | Type | Description |
1348
+ | --------- | ---------- | ------------------------------------- |
1349
+ | `element` | `Element` | The root to scope every query to |
1350
+
1351
+ **Returns:** `QueryScope`
1352
+
1353
+ **Example**
1354
+
1355
+ ```ts
1356
+ import { within } from '@vielzeug/assay';
1357
+
1358
+ const { query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText } = within(panel);
1359
+ ```
1360
+
1361
+ `QueryScope` methods:
1362
+
1363
+ | Method | Returns |
1364
+ | ---------------------------------- | ----------------- |
1365
+ | `query(selector)` | `Element \| null` |
1366
+ | `queryAll(selector)` | `Element[]` |
1367
+ | `queryByText(text, selector?)` | `Element \| null` |
1368
+ | `queryAllByText(text, selector?)` | `Element[]` |
1369
+ | `queryByTestId(testId)` | `Element \| null` |
1370
+ | `queryAllByTestId(testId)` | `Element[]` |
1371
+
1372
+ `selector` defaults to `'*'` for the text-matching methods.
1373
+
1374
+ ---
1375
+
1376
+ ### `query(root, selector)` / `queryAll(root, selector)`
1377
+
1378
+ Free-function equivalents of `within(root).query`/`.queryAll` — a thin wrapper over `root.querySelector(All)`, exported directly so every `QueryScope` method has both a scoped and unscoped form.
1379
+
1380
+ **Returns:** `Element | null` / `Element[]`
1381
+
1382
+ ---
1383
+
1384
+ ### `queryByTestId(root, testId)` / `queryAllByTestId(root, testId)`
1385
+
1386
+ Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` — matches a `data-testid` attribute.
1387
+
1388
+ **Returns:** `Element | null` / `Element[]`
1389
+
1390
+ ---
1391
+
1392
+ ### `queryByText(root, text, selector)`
1393
+
1394
+ The unscoped function `within()` is built on — useful when you already have a root and don't need the rest of `QueryScope`.
1395
+
1396
+ **Parameters**
1397
+
1398
+ | Name | Type | Description |
1399
+ | ---------- | ---------------------- | ---------------------------------------------- |
1400
+ | `root` | `Element \| ShadowRoot`| Subtree to search |
1401
+ | `text` | `string` | Exact text to match against trimmed `textContent` |
1402
+ | `selector` | `string` | CSS selector narrowing candidate elements |
1403
+
1404
+ **Returns:** `Element | null` — the first matching element, or `null`.
1405
+
1406
+ ---
1407
+
1408
+ ### `queryAllByText(root, text, selector)`
1409
+
1410
+ Same matching rules as `queryByText`, returning every match.
1411
+
1412
+ **Returns:** `Element[]`
1413
+
1414
+ ---
1415
+
1416
+ ### `queryInShadow(host, selector)` / `queryAllInShadow(host, selector)`
1417
+
1418
+ Query inside `host.shadowRoot`. Returns `null` (or `[]` for the `All` variant) instead of throwing when `host` has no shadow root — safe to call without an `if (host.shadowRoot)` guard.
1419
+
1420
+ **Returns:** `Element | null` / `Element[]`
1421
+
1422
+ ---
1423
+
1424
+ ### `queryPart(host, part)`
1425
+
1426
+ Shorthand for `queryInShadow(host, '[part="' + part + '"]')`.
1427
+
1428
+ **Returns:** `Element | null`
1429
+
1430
+ ---
1431
+
1432
+ ### `getSlotted(host, slotName?)`
1433
+
1434
+ Returns the light-DOM children assigned to a named slot, or every slotted child (`:not([slot])`) when `slotName` is omitted.
1435
+
1436
+ **Returns:** `Element[]`
1437
+
1438
+ ## Event Dispatch
1439
+
1440
+ ### `fire`
1441
+
1442
+ An object of synchronous event dispatchers. Every method calls `element.dispatchEvent(...)` with an appropriately-typed `Event` subclass and sensible defaults, and returns `dispatchEvent`'s own boolean result.
1443
+
1444
+ ```ts
1445
+ import { fire } from '@vielzeug/assay';
1446
+
1447
+ fire.click(el, opts?: PointerEventInit);
1448
+ fire.blur(el, opts?: FocusEventInit);
1449
+ fire.change(el, opts?: EventInit);
1450
+ fire.custom(el, name, opts?: CustomEventInit);
1451
+ fire.event(el, event: Event);
1452
+ fire.focus(el, name?, opts?: FocusEventInit);
1453
+ fire.input(el, opts?: EventInit);
1454
+ fire.keyboard(el, type, opts?: KeyboardEventInit);
1455
+ fire.keyDown(el, opts?: KeyboardEventInit);
1456
+ fire.keyUp(el, opts?: KeyboardEventInit);
1457
+ fire.mouse(el, type, opts?: MouseEventInit);
1458
+ fire.pointerCancel(el, opts?: PointerEventInit);
1459
+ fire.pointerDown(el, opts?: PointerEventInit);
1460
+ fire.pointerEnter(el, opts?: PointerEventInit);
1461
+ fire.pointerLeave(el, opts?: PointerEventInit);
1462
+ fire.pointerMove(el, opts?: PointerEventInit);
1463
+ fire.pointerUp(el, opts?: PointerEventInit);
1464
+ fire.submit(el, opts?: EventInit);
1465
+ fire.touch(el, type, opts?: EventInit);
1466
+ ```
1467
+
1468
+ `fire.touch` falls back to `CustomEvent` in environments without a `TouchEvent` constructor. `fire.pointer*` methods route through `createPointerEvent()`, so they fall back to `MouseEvent` the same way.
1469
+
1470
+ ---
1471
+
1472
+ ### `createPointerEvent(type, init?)`
1473
+
1474
+ Builds a `PointerEvent`, or a `MouseEvent` when `PointerEvent` isn't available in the current environment.
1475
+
1476
+ **Returns:** `Event`
1477
+
1478
+ ## Async Waiting
1479
+
1480
+ ### `waitFor(fn, options?)`
1481
+
1482
+ Polls `fn` until it returns truthy, returns `undefined` (a bare `expect()` call that didn't throw), or the timeout elapses.
1483
+
1484
+ **Parameters**
1485
+
1486
+ | Name | Type | Default | Description |
1487
+ | ------------------ | ------------------- | ------- | ---------------------------------------------- |
1488
+ | `fn` | `() => unknown` | — | Condition to poll |
1489
+ | `options.timeout` | `number` | `1000` | Maximum wait time in ms |
1490
+ | `options.interval` | `number` | `50` | Delay between polling attempts in ms |
1491
+ | `options.message` | `string` | — | Prefixed in front of the default timing summary in the timeout error — never replaces it |
1492
+
1493
+ **Returns:** `Promise`
1494
+
1495
+ **Throws:** `AssayTimeoutError` on timeout, unconditionally — regardless of whether the last polling attempt returned a falsy value or threw. The original failure (a thrown error, or its message) is preserved as `.cause` and folded into the timeout message; `fn`'s thrown error itself is never mutated.
1496
+
1497
+ **Example**
1498
+
1499
+ ```ts
1500
+ await waitFor(() => queryByText('Saved') !== null);
1501
+ await waitFor(() => expect(spy).toHaveBeenCalled(), { timeout: 2000 });
1502
+ ```
1503
+
1504
+ ---
1505
+
1506
+ ### `waitForEvent(element, name, timeout?)`
1507
+
1508
+ Resolves with the next event of the given name.
1509
+
1510
+ **Parameters**
1511
+
1512
+ | Name | Type | Default | Description |
1513
+ | --------- | --------- | ------- | --------------------------------- |
1514
+ | `element` | `Element` | — | Element to listen on |
1515
+ | `name` | `string` | — | Event name |
1516
+ | `timeout` | `number` | `1000` | Maximum wait time in ms |
1517
+
1518
+ **Returns:** `Promise` — the event instance, typed as `T extends Event` (defaults to `Event`).
1519
+
1520
+ ---
1521
+
1522
+ ### `nextTick()`
1523
+
1524
+ Resolves after one microtask tick (`queueMicrotask`) — for waiting on reactivity (signal effects, promise-chain continuations) without moving into the macrotask queue.
1525
+
1526
+ **Returns:** `Promise`
1527
+
1528
+ ---
1529
+
1530
+ ### `wait(ms?)`
1531
+
1532
+ Resolves after `ms` milliseconds (default `0`).
1533
+
1534
+ **Returns:** `Promise`
1535
+
1536
+ ## Types
1537
+
1538
+ ```ts
1539
+ interface QueryScope {
1540
+ query(selector: string): E | null;
1541
+ queryAll(selector: string): E[];
1542
+ queryByText(text: string, selector?: string): E | null;
1543
+ queryAllByText(text: string, selector?: string): E[];
1544
+ queryByTestId(testId: string): E | null;
1545
+ queryAllByTestId(testId: string): E[];
1546
+ }
1547
+
1548
+ interface WaitOptions {
1549
+ timeout?: number;
1550
+ interval?: number;
1551
+ message?: string;
1552
+ }
1553
+ ```
1554
+
1555
+ ## Errors
1556
+
1557
+ | Error | Thrown by | Notable properties |
1558
+ | -------------------- | ------------------------------ | -------------------- |
1559
+ | `AssayError` | Base class for every Assay error — use `instanceof AssayError` to catch any of them | `AssayError.is(err)` static type guard |
1560
+ | `AssayTimeoutError` | `waitFor()`, `waitForEvent()` when the timeout elapses | Extends `AssayError` |
1561
+
1562
+ ### Usage Guide
1563
+
1564
+ ## Basic Usage
1565
+
1566
+ `within(element)` scopes queries to a subtree — useful for slotted content, shadow roots, or any container you don't want to re-select from `document` on every assertion.
1567
+
1568
+ ```ts
1569
+ import { within } from '@vielzeug/assay';
1570
+
1571
+ const panel = document.querySelector('.panel')!;
1572
+ const { query, queryAll, queryByText } = within(panel);
1573
+
1574
+ query('.title'); // Element | null, scoped to panel
1575
+ queryAll('.item'); // Element[], scoped to panel
1576
+ queryByText('Save'); // matches trimmed textContent
1577
+ ```
1578
+
1579
+ `queryByTestId`/`queryAllByTestId` match a `data-testid` attribute — useful when text content or CSS structure is likely to change but a stable test hook is worth keeping:
1580
+
1581
+ ```ts
1582
+ const { queryByTestId } = within(panel);
1583
+
1584
+ queryByTestId('save-button')?.click();
1585
+ ```
1586
+
1587
+ Every `QueryScope` method is also available as a free function taking the root as its first argument — reach for these when you already have a root in scope and don't need the rest of `within()`'s object:
1588
+
1589
+ ```ts
1590
+ import { query, queryAll, queryByTestId, queryAllByTestId } from '@vielzeug/assay';
1591
+
1592
+ query(panel, '.title');
1593
+ queryAll(panel, '.item');
1594
+ queryByTestId(panel, 'save-button');
1595
+ queryAllByTestId(panel, 'item-row');
1596
+ ```
1597
+
1598
+ For a custom element's shadow DOM, use `queryInShadow`/`queryAllInShadow`/`queryPart` instead — they return `null`/`[]` rather than throwing when the host has no shadow root (e.g. a `shadow: false` component), so you don't need an `if (host.shadowRoot)` guard at every call site:
1599
+
1600
+ ```ts
1601
+ import { queryAllInShadow, queryInShadow, queryPart } from '@vielzeug/assay';
1602
+
1603
+ queryInShadow(customEl, '.internal-label');
1604
+ queryAllInShadow(customEl, '.option');
1605
+ queryPart(customEl, 'trigger'); // shorthand for queryInShadow(host, '[part="trigger"]')
1606
+ ```
1607
+
1608
+ `getSlotted()` reads the *light*-DOM children a host projects into a named slot (or every slotted child with no argument) — the complement to the shadow-DOM helpers above:
1609
+
1610
+ ```ts
1611
+ import { getSlotted } from '@vielzeug/assay';
1612
+
1613
+ const slides = getSlotted(carousel); // default slot
1614
+ const actions = getSlotted(dialog, 'footer'); // named slot
1615
+ ```
1616
+
1617
+ ## Firing Events
1618
+
1619
+ `fire.*` dispatches real DOM events synchronously — no framework-specific event simulation layer, just `dispatchEvent` with sensible defaults (`bubbles: true`, `cancelable: true` where that matches the real event's behavior). Every method returns the same `boolean` `dispatchEvent` itself returns (`false` when a listener called `preventDefault()`), with no exceptions — useful for asserting a handler actually intercepted the event:
1620
+
1621
+ ```ts
1622
+ import { fire } from '@vielzeug/assay';
1623
+
1624
+ fire.click(button);
1625
+ fire.input(textInput); // fires a plain 'input' Event
1626
+ fire.keyDown(textInput, { key: 'Enter' });
1627
+ fire.custom(el, 'value-change', { detail: { value: 42 } });
1628
+
1629
+ const notPrevented = fire.click(link); // false if a listener called preventDefault()
1630
+ ```
1631
+
1632
+ Pointer events fall back to `MouseEvent` in environments without a `PointerEvent` constructor (some older jsdom versions) — Assay logs a one-time `console.warn` the first time this happens, so an environment gap doesn't show up as a confusing, unrelated test failure instead:
1633
+
1634
+ ```ts
1635
+ fire.pointerDown(slider);
1636
+ fire.pointerMove(slider, { clientX: 120 });
1637
+ fire.pointerUp(slider);
1638
+ ```
1639
+
1640
+ Dispatch a pre-built event instance directly with `fire.event` when you need full control over the event's properties:
1641
+
1642
+ ```ts
1643
+ fire.event(el, new CustomEvent('ready', { bubbles: true, detail: { ok: true } }));
1644
+ ```
1645
+
1646
+ ## Waiting for Async Conditions
1647
+
1648
+ `waitFor()` polls a callback until it returns truthy (or doesn't throw, for a bare `expect()` call) — use it for anything that updates asynchronously (a reactive framework's next render, a debounced input handler, a `fetch` completing).
1649
+
1650
+ ```ts
1651
+ import { waitFor } from '@vielzeug/assay';
1652
+
1653
+ await waitFor(() => panel.querySelector('.status')?.textContent === 'Ready');
1654
+
1655
+ // Works with `expect()` assertions too — a thrown assertion error means "not yet",
1656
+ // and its message is folded into the AssayTimeoutError thrown if the timeout is reached
1657
+ // (see below — the assertion's own error type never escapes waitFor() directly).
1658
+ await waitFor(() => expect(callback).toHaveBeenCalled());
1659
+ ```
1660
+
1661
+ Tune the polling interval and timeout per call — the defaults (1000ms timeout, 50ms interval) suit most reactive UI updates:
1662
+
1663
+ ```ts
1664
+ await waitFor(() => queue.isEmpty(), { interval: 10, timeout: 5000 });
1665
+ ```
1666
+
1667
+ `waitForEvent()` resolves with the next matching event instead of polling — prefer it when you're waiting on something that's guaranteed to fire an event rather than settle into an observable DOM state:
1668
+
1669
+ ```ts
1670
+ import { fire, waitForEvent } from '@vielzeug/assay';
1671
+
1672
+ const promise = waitForEvent>(el, 'item-added');
1673
+
1674
+ fire.click(addButton);
1675
+
1676
+ const event = await promise;
1677
+ console.log(event.detail.id);
1678
+ ```
1679
+
1680
+ Both `waitFor()` and `waitForEvent()` reject with `AssayTimeoutError` on timeout — unconditionally, even if the last attempt inside `waitFor()` threw a different error type (an `expect()` assertion, say). Catch `AssayTimeoutError` specifically when you want to distinguish "the condition never became true" from other failures; the original failure is preserved on `.cause` if you need it:
1681
+
1682
+ ```ts
1683
+ import { AssayTimeoutError, waitForEvent } from '@vielzeug/assay';
1684
+
1685
+ try {
1686
+ await waitForEvent(el, 'never-fires', 100);
1687
+ } catch (err) {
1688
+ if (err instanceof AssayTimeoutError) {
1689
+ // expected — assert on the timeout itself, or inspect err.cause for the original failure
1690
+ }
1691
+ }
1692
+ ```
1693
+
1694
+ `nextTick()` and `wait()` cover the two other common timing needs: waiting for reactivity to settle, and waiting for a real macrotask delay.
1695
+
1696
+ ```ts
1697
+ import { nextTick, wait } from '@vielzeug/assay';
1698
+
1699
+ signal.value = 'updated';
1700
+ await nextTick(); // let a microtask-scheduled effect run
1701
+
1702
+ await wait(300); // wait out a debounce timer — prefer nextTick()/waitFor() where possible
1703
+ ```
1704
+
1705
+ ## Testing Custom Elements
1706
+
1707
+ Assay has no opinion about how your DOM was produced — it works the same way against a vanilla custom element, a framework-rendered component, or plain `document.createElement` output.
1708
+
1709
+ ```ts
1710
+ import { fire, waitFor, within } from '@vielzeug/assay';
1711
+
1712
+ customElements.define(
1713
+ 'my-counter',
1714
+ class extends HTMLElement {
1715
+ connectedCallback() {
1716
+ this.innerHTML = `+10`;
1717
+ this.querySelector('button')!.addEventListener('click', () => {
1718
+ const span = this.querySelector('.count')!;
1719
+
1720
+ span.textContent = String(Number(span.textContent) + 1);
1721
+ });
1722
+ }
1723
+ },
1724
+ );
1725
+
1726
+ const el = document.createElement('my-counter');
1727
+
1728
+ document.body.appendChild(el);
1729
+
1730
+ const { query } = within(el);
1731
+
1732
+ fire.click(query('button')!);
1733
+
1734
+ await waitFor(() => query('.count')?.textContent === '1');
1735
+
1736
+ el.remove();
1737
+ ```
1738
+
1739
+ ## Working with Other Vielzeug Libraries
1740
+
1741
+ `@vielzeug/ore`'s `./testing` sub-path re-exports Assay's `within`, `fire`, `createPointerEvent`, `waitFor`, and `waitForEvent` directly — if you're already testing Ore components, you don't need a separate Assay import:
1742
+
1743
+ ```ts
1744
+ import { fire, mount, waitFor } from '@vielzeug/ore/testing';
1745
+
1746
+ const { query } = await mount(() => html` {}}>Click`);
1747
+
1748
+ fire.click(query('button')!);
1749
+ ```
1750
+
1751
+ `@vielzeug/refine`'s `./testing` sub-path re-exports Assay's `queryInShadow`, `queryAllInShadow`, `queryPart`, `getSlotted`, `nextTick`, and `wait` the same way, alongside refine-specific helpers (ARIA attribute assertions, form-associated helpers) that stay in refine because they're specific to testing ore/refine component *contracts*, not generic DOM interaction:
1752
+
1753
+ ```ts
1754
+ import { getAriaState, getSlotted, nextTick } from '@vielzeug/refine/testing';
1755
+ ```
1756
+
1757
+ Import from `@vielzeug/assay` directly when testing DOM code that has nothing to do with Ore or Refine — a vanilla custom element, a framework-rendered component, or plain event-handler logic.
1758
+
1759
+ ## Best Practices
1760
+
1761
+ - Prefer `within(element)` over repeated `element.querySelector(...)` calls — it reads better at call sites with multiple assertions against the same subtree.
1762
+ - Use `queryByTestId` for elements whose text or structure is expected to change; use `queryByText` when the visible text itself is what you're asserting on.
1763
+ - Reach for `waitForEvent()` over `waitFor()` when the thing you're waiting on is guaranteed to dispatch an event — it resolves on the first matching event instead of polling.
1764
+ - Catch `AssayTimeoutError` specifically (not a bare `Error`) when a test needs to branch on "the condition never happened" versus any other failure.
1765
+ - Keep `fire.*` calls synchronous where the real user interaction would be — `fire.click()` doesn't await anything; `await` the assertion that follows it instead.
1766
+ - Remove elements you create directly with `document.createElement` (`el.remove()`) at the end of each test — Assay itself has no auto-cleanup registry, unlike `@vielzeug/ore/testing`'s `mount()`/`cleanup()`.
1767
+ - Prefer `queryInShadow`/`queryAllInShadow` over `host.shadowRoot!.querySelector(...)` — the non-null assertion breaks the moment a component is tested with `shadow: false`, while `queryInShadow` degrades to `null` instead of throwing.
1768
+
1769
+ ### Examples
1770
+
1771
+ ## Examples
1772
+
1773
+ - [Custom Element Interaction](./examples/custom-element-interaction.md)
1774
+ - [Waiting for Async Updates](./examples/waiting-for-async-updates.md)
1775
+
1776
+
1214
1777
  ---
1215
1778
 
1216
1779
  ## @vielzeug/clockwork
@@ -12988,17 +13551,15 @@ if (!submission.ok && submission.type === 'validation') {
12988
13551
 
12989
13552
  - Typed field paths with compile-time value inference
12990
13553
  - Explicit validation API: `validate()`, `validate(name)`, and `validate(fields[])`
12991
- - Streaming validation with `validateStream()` — yields each field result as it resolves, read-only
12992
13554
  - Per-connection validation triggers via `connect()` with `ValidationModes` presets
12993
13555
  - `connect()` bindings own independent debounce timers; call `binding.dispose()` on unmount
12994
13556
  - `submit(handler)` — returns `{ ok: true, value }` or `{ ok: false, errors }`
12995
13557
  - Schema integration: pass any `safeParse`-compatible schema directly to `validator`
12996
- - `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths
12997
- - `subscribeScoped()` filtered subscription that only fires on changes within the scope's prefix
12998
- - `snapshot()` / `restore()` — capture and replay complete form state
13558
+ - `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths; `subscribe()` on a scoped form filters to just that prefix
13559
+ - `history.snapshot()` / `history.restore()` capture and replay complete form state
12999
13560
  - `form.fields.remove(name)` — clean conditional field lifecycle
13000
13561
  - Full array helpers: `append`, `prepend`, `insert`, `remove`, `move`, `swap`, `replace`
13001
- - Explicit synchronous subscriptions: `subscribe`, `subscribeField`, and `subscribeScoped`
13562
+ - Explicit synchronous subscriptions: `subscribe` (form or scoped-form state) and `subscribeField` (one field)
13002
13563
  - Stable frozen snapshots for `form.state` and `form.field(name)` (external-store friendly)
13003
13564
  - Explicit touched and error controls: `touch`, `untouch`, `touchAll`, `untouchAll`, `setError`, `resetErrors`
13004
13565
  - Mutation batching with `batch(fn)` and dynamic field validators via `fields.setValidator`
@@ -13030,13 +13591,12 @@ if (!submission.ok && submission.type === 'validation') {
13030
13591
  | `form.get()` / `form.set()` | Read/write field values by dot-path | Sync | `set()` after `dispose()` throws |
13031
13592
  | `form.field()` / `form.state` | Read field and form snapshots | Sync | Returns a stable frozen snapshot; re-read on each subscriber call |
13032
13593
  | `form.validate()` | Run validation — all fields, a subset, or a single field | Async | Each call re-runs validators from scratch |
13033
- | `form.validateStream()` | Streaming validation — yields each field result as it resolves | Async (iterator) | Read-only — does not write errors to form state |
13034
13594
  | `form.submit()` | Deterministic submit flow returning a `SubmitResult` | Async | Rejects if called while already submitting — guard with `form.isSubmitting` |
13035
13595
  | `form.connect()` | Live field binding with DOM event handlers and live getters | Sync | Do not destructure — live getters lose context; call `dispose()` on unmount |
13036
13596
  | `form.scope()` | Memoized scoped sub-form with relative field paths | Sync | Returns the same object for repeated calls with the same prefix; `state` is scoped — flags reflect only prefix fields |
13037
13597
  | `form.array()` | Array mutation helpers | Sync | Returns a cached helper — call once and reuse |
13038
- | `form.subscribe()` / `form.subscribeField()` / `form.subscribeScoped()` | Synchronous form and field subscriptions | Sync | Callbacks receive frozen snapshots |
13039
- | `form.snapshot()` / `form.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving |
13598
+ | `form.subscribe()` / `form.subscribeField()` | Synchronous form and field subscriptions | Sync | On a scoped form, `subscribe()` is already prefix-filtered — callbacks receive frozen snapshots |
13599
+ | `form.history.snapshot()` / `form.history.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving |
13040
13600
  | `form.batch()` | Group mutations into one notification | Sync | Nested `batch()` calls are safe — only the outermost flush notifies |
13041
13601
  | `form.touch()` / `form.touchAll()` | Mark fields touched | Sync | `touchAll()` marks every key currently in the store |
13042
13602
  | `form.setError()` / `form.clearError()` / `form.resetErrors()` | Manual error management | Sync | `setError()` bypasses validators; cleared on next `validate()` run for that field |
@@ -13349,11 +13909,6 @@ subscribeField>(
13349
13909
  options?: SubscribeOptions,
13350
13910
  ): Unsubscribe
13351
13911
 
13352
- subscribeScoped(
13353
- listener: (state: FormState) => void,
13354
- options?: SubscribeOptions,
13355
- ): Unsubscribe
13356
-
13357
13912
  type SubscribeOptions = { sync?: boolean };
13358
13913
  type Unsubscribe = () => void;
13359
13914
  ```
@@ -13362,17 +13917,17 @@ Pass `{ sync: true }` to also receive the current snapshot immediately upon subs
13362
13917
 
13363
13918
  Subscriptions fire synchronously whenever the form mutates. Because state snapshots are stable (frozen, reference-equal between mutations), these integrate directly with React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
13364
13919
 
13365
- ### subscribeScoped
13920
+ ### subscribe on a scoped form
13366
13921
 
13367
- `subscribeScoped` is available on both root forms and scoped forms:
13922
+ `subscribe` behaves differently depending on which form object it's called on:
13368
13923
 
13369
13924
  - **On a scoped form** — filters `errors`, `touchedFields`, and `validatingFields` to paths within the scope's prefix (remapped to relative paths). The listener is **only called when the scoped projection changes** — mutations outside the scope are suppressed. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form.
13370
- - **On a root form** — behaves identically to `subscribe`; no filtering is applied.
13925
+ - **On a root form** — no filtering is applied; every mutation notifies the listener.
13371
13926
 
13372
13927
  ```ts
13373
13928
  const address = form.scope('address');
13374
13929
 
13375
- address.subscribeScoped((state) => {
13930
+ address.subscribe((state) => {
13376
13931
  // state.errors uses relative keys: { city: '...' } not { 'address.city': '...' }
13377
13932
  // only fires when an address.* field changes
13378
13933
  console.log(state.errors, state.touchedFields);
@@ -13399,11 +13954,13 @@ type ArrayField = {
13399
13954
 
13400
13955
  `append()` and `prepend()` initialize the field as a one-item array when its current value is `undefined` or `null`. If the field already holds a non-array, non-nullish value (e.g. written by `set()`), both are a no-op — they never overwrite an existing scalar with an array. `insert()`, `remove()`, `move()`, `swap()`, and `replace()` are all no-ops when the field's current value is not an array.
13401
13956
 
13402
- ## Snapshot / Restore
13957
+ ## History (Snapshot / Restore)
13403
13958
 
13404
13959
  ```ts
13405
- snapshot(): FormSnapshot
13406
- restore(snap: FormSnapshot): void
13960
+ history: {
13961
+ snapshot(): FormSnapshot;
13962
+ restore(snap: FormSnapshot): void;
13963
+ }
13407
13964
 
13408
13965
  type FormSnapshot = {
13409
13966
  readonly baseline: Partial, unknown>>;
@@ -13415,44 +13972,17 @@ type FormSnapshot = {
13415
13972
  };
13416
13973
  ```
13417
13974
 
13418
- - `snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
13419
- - `restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
13975
+ - `history.snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
13976
+ - `history.restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
13420
13977
 
13421
- Useful for undo/redo, draft saving, and "discard changes" flows:
13978
+ Namespaced off the main `Form` surface since it's a distinctly less common operation than reading/writing values — grouped the same way `fields` already groups dynamic-field-lifecycle operations. Useful for undo/redo, draft saving, and "discard changes" flows:
13422
13979
 
13423
13980
  ```ts
13424
- const draft = form.snapshot();
13981
+ const draft = form.history.snapshot();
13425
13982
 
13426
13983
  form.set('email', 'changed@example.com');
13427
13984
 
13428
- form.restore(draft); // reverts all changes
13429
- ```
13430
-
13431
- ## validateStream()
13432
-
13433
- ```ts
13434
- validateStream(signal?: AbortSignal): AsyncIterableIterator
13435
- ```
13436
-
13437
- Runs all field validators in parallel and yields each result as soon as its validator resolves. If a form-level validator is configured, all keys it returns are yielded last — including `field: '_form'` and any field-specific keys returned by the form validator.
13438
-
13439
- **Read-only**: `validateStream()` does not write to `fieldErrors` or trigger subscriber notifications. Use `validate()` when you want errors applied to form state.
13440
-
13441
- ```ts
13442
- for await (const { field, error } of form.validateStream()) {
13443
- if (error) showInlineError(field, error);
13444
- }
13445
- // After the loop: form.state.errors is unchanged
13446
- ```
13447
-
13448
- Pass an `AbortSignal` to cancel the stream:
13449
-
13450
- ```ts
13451
- const ctrl = new AbortController();
13452
- for await (const result of form.validateStream(ctrl.signal)) {
13453
- processResult(result);
13454
- }
13455
- ctrl.abort(); // cancels any remaining in-flight validators
13985
+ form.history.restore(draft); // reverts all changes
13456
13986
  ```
13457
13987
 
13458
13988
  ## Baseline and Value Management
@@ -13648,12 +14178,15 @@ type ForgeDevtoolsOptions
13648
14178
 
13649
14179
  // Utility types
13650
14180
  type DeepPartial
13651
- type FlatKeyOf
14181
+ type FlatKeyOf // capped at MAX_TYPED_PATH_DEPTH (5) — see below
13652
14182
  type TypeAtPath
13653
14183
  type ErrorKeyOf
13654
14184
  type ScopedValues
14185
+ const MAX_TYPED_PATH_DEPTH // = 5 — not user-configurable, see rationale below
13655
14186
  ```
13656
14187
 
14188
+ `FlatKeyOf` falls back to plain `string` for paths deeper than `MAX_TYPED_PATH_DEPTH` — nothing throws, you just lose autocomplete/type-checking on that specific path (a dev-only console warning fires once per deep key so this is discoverable, not silent). This is a fixed constant, not a per-form generic parameter: it exists purely to protect TypeScript compile time, which doesn't get safer by letting one form's type param regress it for everyone importing that form's module.
14189
+
13657
14190
  ## Errors
13658
14191
 
13659
14192
  Forge exports a small typed error hierarchy, all extending a common `ForgeError` base:
@@ -13905,7 +14438,7 @@ await address.submit((vals) => vals); // validates and submits only address.* fi
13905
14438
 
13906
14439
  1. Call `const address = form.scope('address')` once per UI/module boundary and pass that around.
13907
14440
  2. Use `address.validate()` / `address.submit()` instead of manually feeding `state.touchedFields` into `validate(fields[])`.
13908
- 3. Prefer `address.subscribeScoped(...)` for section UIs so sibling/root mutations do not trigger redraws.
14441
+ 3. Use `address.subscribe(...)` for section UIs — on a scoped form `subscribe` is already prefix-filtered, so sibling/root mutations do not trigger redraws.
13909
14442
 
13910
14443
  **Key characteristics:**
13911
14444
 
@@ -13914,12 +14447,12 @@ await address.submit((vals) => vals); // validates and submits only address.* fi
13914
14447
 
13915
14448
  ### Scoped Subscriptions
13916
14449
 
13917
- `subscribeScoped` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed.
14450
+ On a scoped form, `subscribe()` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed. (On a root form, `subscribe()` behaves identically to today — no filtering applies.)
13918
14451
 
13919
14452
  ```ts
13920
14453
  const address = form.scope('address');
13921
14454
 
13922
- address.subscribeScoped((state) => {
14455
+ address.subscribe((state) => {
13923
14456
  // state.errors → { city: 'Required' } (not 'address.city')
13924
14457
  // state.isDirty → true only when an address.* field is dirty
13925
14458
  // does not fire when form.set('name', 'Alice') is called
@@ -13951,29 +14484,18 @@ Snapshot semantics:
13951
14484
  - Reference identity is preserved until a relevant mutation occurs.
13952
14485
  - These are directly compatible with external-store patterns such as React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
13953
14486
 
13954
- ## Streaming Validation
14487
+ ## History (Snapshot / Restore)
13955
14488
 
13956
- `validateStream()` runs all field validators in parallel and yields each result as it resolves. It is **read-only** — it does not write errors to form state. The form-level validator, if set, is yielded last with `field: '_form'`.
14489
+ Capture and replay complete form state for undo/redo or "discard changes" flows, via the `history` namespace:
13957
14490
 
13958
14491
  ```ts
13959
- for await (const { field, error } of form.validateStream()) {
13960
- if (error) showInlineError(field, error);
13961
- }
13962
- // form.state.errors is unchanged after the loop
13963
- ```
13964
-
13965
- ## Snapshots and Restore
13966
-
13967
- Capture and replay complete form state for undo/redo or "discard changes" flows:
13968
-
13969
- ```ts
13970
- const draft = form.snapshot();
14492
+ const draft = form.history.snapshot();
13971
14493
 
13972
14494
  // ... user edits ...
13973
14495
  form.set('email', 'different@example.com');
13974
14496
 
13975
14497
  // Revert all changes, including errors, touched, dirty, and submitCount
13976
- form.restore(draft);
14498
+ form.history.restore(draft);
13977
14499
  ```
13978
14500
 
13979
14501
  ## Arrays
@@ -14219,7 +14741,6 @@ const formWithSchema = createForm({
14219
14741
  - Field & Form Validation (id: `form-validation`)
14220
14742
  - Schema Integration - safeParse Auto-detection (id: `schema-integration`)
14221
14743
  - Scoped Sub-Forms (scope) (id: `scoped-sub-forms`)
14222
- - Streaming Validation (validateStream) (id: `validate-stream`)
14223
14744
 
14224
14745
 
14225
14746
  ---
@@ -17706,7 +18227,7 @@ effect(() => {
17706
18227
 
17707
18228
  **Category:** i18n
17708
18229
  **Keywords:** internationalization, translations, pluralization, locale, i18n, l10n, async-loading
17709
- **Key exports:** createI18n, createFormatter, hydrateI18n, serializeI18n, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError (+1 more)
18230
+ **Key exports:** createI18n, createFormatter, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError, LinguaRestoreError
17710
18231
  **Related:** ripple, wayfinder, courier
17711
18232
 
17712
18233
  ### Overview
@@ -17821,7 +18342,7 @@ i18n.getSupportedLocales();
17821
18342
  - Namespace lazy loading: `registerNamespace(ns, factory)` + `loadNamespace(ns, locale?)` — or use `extend(ns, factory, locale?)` as a convenience that does both; deduplicates per `ns + locale`; use for per-route or per-feature keys
17822
18343
  - Scoped translation helpers: `scope(prefix)` returns a `{ fmt, t, tp, has }` helper bound to a key prefix
17823
18344
  - Unified key existence check: `has(key)` returns `true` for leaf keys, branch keys, and pipe-plural base keys in the active fallback chain
17824
- - Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `serializeI18n()` guards
18345
+ - Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `getState()` guards
17825
18346
  - Registered-locale predicate: `isRegistered(locale)` distinguishes "never configured" from "async loader not yet called"
17826
18347
  - Instance disposal: `dispose()` clears all subscribers and catalog state — prevents memory leaks in route-scoped SPA instances
17827
18348
  - Typed error handling: every thrown/rejected error is `instanceof LinguaError`, with named subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, `LinguaNamespaceMissingError`, …) for specific `instanceof` branching
@@ -17830,6 +18351,7 @@ i18n.getSupportedLocales();
17830
18351
  - Deterministic fallback chain using active locale plus configured fallback locales
17831
18352
  - Separate missing handlers: `onMissingKey(key, locale)` and `onMissingVar(varName, key, locale)`
17832
18353
  - Formatting via `createFormatter(source)` — exported from the main entry alongside `createI18n`
18354
+ - Automatic dev-mode plural-form validation: every catalog registered or loaded is checked against CLDR rules for its locale, logged via `console.warn` — zero setup, and never bundled into production
17833
18355
 
17834
18356
  ## Documentation
17835
18357
 
@@ -17869,13 +18391,11 @@ i18n.getSupportedLocales();
17869
18391
  | `i18n.loadNamespace()` | Load a registered namespace for a locale | Async | Deduplicates concurrent and repeated calls; throws `LinguaNamespaceMissingError` if namespace not registered |
17870
18392
  | `i18n.isNamespaceLoaded()` | Check if a namespace is loaded for the active (or given) locale | Sync | Returns `false` if not registered or not yet loaded for this locale |
17871
18393
  | `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before |
17872
- | `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Equivalent to `serializeI18n(i18n)` preferred for public API access |
17873
- | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(i18n, state)` preferred for public API access; throws `LinguaRestoreError` if locale missing |
17874
- | `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
17875
- | `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
18394
+ | `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
18395
+ | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
17876
18396
  | Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling |
17877
18397
  | `createFormatter()` | Create a standalone Intl formatter | Sync | Available from the main entry or `@vielzeug/lingua/format` — pass a getter `() => i18n.locale` to follow locale changes |
17878
- | `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` — not for production |
18398
+ | `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` for CI enforcement `createI18n()` already runs the same check automatically in dev builds |
17879
18399
 
17880
18400
  ## Package Entry Points
17881
18401
 
@@ -18073,7 +18593,7 @@ Returns `true` if a namespace factory has been registered under `ns` via `regist
18073
18593
  getState(): I18nState
18074
18594
  ```
18075
18595
 
18076
- Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale. Equivalent to `serializeI18n(i18n)` but preferred because it is called directly on the instance without requiring an import.
18596
+ Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale.
18077
18597
 
18078
18598
  **Warning:** Only fully resolved catalogs are included. Loader-only locales not yet preloaded are omitted. Use `i18n.isLoaded(locale)` to verify before calling.
18079
18599
 
@@ -18088,13 +18608,15 @@ const state = i18n.getState();
18088
18608
  restoreState(state: I18nState): void
18089
18609
  ```
18090
18610
 
18091
- Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(i18n, state)` but preferred because it is called directly on the instance.
18611
+ Hydrates this instance from an `I18nState` produced by `getState()`.
18092
18612
 
18093
18613
  - Replaces all catalogs with those from `state`.
18094
18614
  - Sets the active locale to `state.locale`.
18095
18615
  - Clears all namespace loaded-markers so that `extend()` / `loadNamespace()` can re-apply namespaces.
18096
18616
  - Notifies subscribers.
18097
18617
 
18618
+ Unlike `register()` and construction, this does **not** run the automatic dev-mode plural-form check (see [`validateCatalog()`](#validatecatalog)) — `state` is assumed to already have been registered, and therefore already checked, once on whatever system produced it.
18619
+
18098
18620
  Throws `LinguaRestoreError` if `state.locale` has no catalog in `state.catalogs`.
18099
18621
  Throws `LinguaDisposedError` if called on a disposed instance.
18100
18622
 
@@ -18218,13 +18740,13 @@ isLoaded(locale: Locale): boolean
18218
18740
 
18219
18741
  Returns `true` if the catalog for `locale` is fully resolved (i.e. not a pending async loader). Returns `false` for unregistered locales, pending loaders, and invalid locale tags — never throws.
18220
18742
 
18221
- Primary use case: guarding `serializeI18n()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded.
18743
+ Primary use case: guarding `getState()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded.
18222
18744
 
18223
18745
  ```ts
18224
18746
  // SSR guard — ensure all locales are loaded before serialising
18225
18747
  const locales = i18n.getSupportedLocales();
18226
18748
  await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l)));
18227
- const state = serializeI18n(i18n); // now includes all locales
18749
+ const state = i18n.getState(); // now includes all locales
18228
18750
  ```
18229
18751
 
18230
18752
  ### `isRegistered()`
@@ -18246,7 +18768,7 @@ Use `isRegistered` + `isLoaded` together to distinguish the three states:
18246
18768
  ```ts
18247
18769
  if (!i18n.isRegistered('fr')) throw new Error('fr locale not configured');
18248
18770
  if (!i18n.isLoaded('fr')) await i18n.preload('fr');
18249
- const state = serializeI18n(i18n); // 'fr' guaranteed to be present
18771
+ const state = i18n.getState(); // 'fr' guaranteed to be present
18250
18772
  ```
18251
18773
 
18252
18774
  ### `disposalSignal`
@@ -18330,6 +18852,8 @@ Checks a flat or nested message catalog against CLDR plural rules for `locale`.
18330
18852
 
18331
18853
  Returns an empty array when there are no issues.
18332
18854
 
18855
+ **Automatic dev-mode checks:** `createI18n()` already calls this internally, in dev builds only, every time a catalog becomes fully available — at construction (`createI18n({ catalogs })`), via `register()`, or once an async loader resolves — logging any warning through `console.warn`. Call `validateCatalog()` directly only when you want CI to fail the build on a warning rather than just log it; the automatic check already covers everyday authoring feedback with zero setup. The automatic check loads `validate.ts`'s logic as a separate, lazily-fetched chunk — it's never part of your production bundle either way.
18856
+
18333
18857
  **Note:** A branch is treated as a plural branch when any of its child keys is a CLDR form (`zero`, `one`, `two`, `few`, `many`, `other`). A mixed-use branch (e.g. `{ count: 'x', one: 'y' }`) will also be flagged and may produce spurious warnings for non-CLDR sibling keys.
18334
18858
 
18335
18859
  `validateCatalog` also checks for a common authoring error: a form template for `other`, `two`, `few`, or `many` that does not contain `{count}`. Since `tp()` injects `count` automatically, omitting it from a non-singleton form is almost always a mistake. These warnings use `form: ':missing-count'` (e.g. `'other:missing-count'`). The `zero` and `one` forms are exempt — intentionally omitting `{count}` is normal there (e.g. `'No messages'`, `'One message'`).
@@ -18449,7 +18973,7 @@ type I18nState = {
18449
18973
  };
18450
18974
  ```
18451
18975
 
18452
- Produced by `getState()` / `serializeI18n()` and consumed by `restoreState()` / `hydrateI18n()`. Catalogs are stored as flat dot-notation maps.
18976
+ Produced by `getState()` and consumed by `restoreState()`. Catalogs are stored as flat dot-notation maps.
18453
18977
 
18454
18978
  ### `NamespaceFactory`
18455
18979
 
@@ -18608,50 +19132,25 @@ type ListFormatOptions = {
18608
19132
  };
18609
19133
  ```
18610
19134
 
18611
- ## serializeI18n
19135
+ ## SSR: `getState()` / `restoreState()`
18612
19136
 
18613
- ```ts
18614
- import { serializeI18n } from '@vielzeug/lingua';
18615
-
18616
- serializeI18n(i18n: I18n): I18nState
18617
- ```
18618
-
18619
- Serialises the current loaded catalogs and active locale into an `I18nState` object. Use this on the server before embedding state in the HTML response. Loader-only locales that have not been preloaded are silently omitted — call `isLoaded()` to verify all locales are resolved before calling `serializeI18n()`.
19137
+ No standalone functions — call these directly on an instance (see [`getState()`](#getstate) / [`restoreState()`](#restorestate) above).
18620
19138
 
18621
19139
  ```ts
18622
19140
  // Server
18623
19141
  const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' });
18624
- const state = serializeI18n(i18n);
19142
+ const state = i18n.getState();
18625
19143
  // Embed in the HTML response:
18626
19144
  // window.__I18N__ = ${JSON.stringify(state)}
18627
19145
  ```
18628
19146
 
18629
- ## hydrateI18n
18630
-
18631
- ```ts
18632
- import { hydrateI18n } from '@vielzeug/lingua';
18633
-
18634
- hydrateI18n(i18n: I18n, state: I18nState): void
18635
- ```
18636
-
18637
- Hydrates a client-side instance from server-serialised state. Replaces all catalogs and switches the active locale. Notifies subscribers once after hydration.
18638
-
18639
- Throws `LinguaRestoreError` if `state.locale` has no corresponding entry in `state.catalogs`.
18640
-
18641
19147
  ```ts
18642
19148
  // Client
18643
- const i18n = createI18n();
18644
- hydrateI18n(i18n, window.__I18N__);
19149
+ const client = createI18n();
19150
+ client.restoreState(window.__I18N__);
18645
19151
  // Catalogs from state are immediately available; no network request needed.
18646
19152
  ```
18647
19153
 
18648
- **Parameters:**
18649
-
18650
- | Parameter | Type | Description |
18651
- | --------- | ----------- | ------------------------------------------ |
18652
- | `i18n` | `I18n` | The instance to hydrate. |
18653
- | `state` | `I18nState` | State object produced by `serializeI18n()` |
18654
-
18655
19154
  ## Error Classes
18656
19155
 
18657
19156
  All errors thrown by the `@vielzeug/lingua` runtime extend `LinguaError`. Use `instanceof LinguaError` to catch any lingua error, or `instanceof` the specific subclass for precise handling.
@@ -18679,7 +19178,7 @@ try {
18679
19178
  | `LinguaMissingLocaleError` | `preload()` / `setLocale()` — locale has no registered source |
18680
19179
  | `LinguaInvalidLocaleError` | Any API receiving an invalid BCP 47 tag |
18681
19180
  | `LinguaNamespaceMissingError` | Namespace requested but not loaded for the current locale |
18682
- | `LinguaRestoreError` | `hydrateI18n()` / `restoreState()` — `state.locale` absent from `state.catalogs` |
19181
+ | `LinguaRestoreError` | `restoreState()` — `state.locale` absent from `state.catalogs` |
18683
19182
 
18684
19183
  ### Usage Guide
18685
19184
 
@@ -18843,7 +19342,9 @@ Without `onMissingKey`, missing keys return the key string. Without `onMissingVa
18843
19342
 
18844
19343
  ## Validating Catalogs
18845
19344
 
18846
- Use `validateCatalog()` during development or CI to detect plural branches that are missing CLDR forms for a target locale. Import it from the dedicated `@vielzeug/lingua/validate` entry never from the main entry or it will end up in your production bundle.
19345
+ `createI18n()` already runs this check automatically in dev builds — every time a catalog becomes available (construction, `register()`, or an async loader resolving), it's checked against CLDR plural rules and any issue is logged via `console.warn`. This costs nothing in production: the check runs behind the same dev-only gate as the rest of `@vielzeug/lingua`'s dev warnings, and the validation logic itself only loads as a separate on-demand chunk, never bundled into your app.
19346
+
19347
+ For CI enforcement (failing a build rather than just warning), call `validateCatalog()` directly. Import it from the dedicated `@vielzeug/lingua/validate` entry — never from the main entry or it will end up in your production bundle.
18847
19348
 
18848
19349
  ```ts
18849
19350
  import { validateCatalog } from '@vielzeug/lingua/validate';
@@ -18882,7 +19383,7 @@ Key characteristics:
18882
19383
 
18883
19384
  ## SSR Hydration
18884
19385
 
18885
- Prefer the instance methods `getState()` and `restoreState()` they are equivalent to `serializeI18n` / `hydrateI18n` but require no extra imports:
19386
+ Use the instance methods `getState()` on the server and `restoreState()` on the client:
18886
19387
 
18887
19388
  ```ts
18888
19389
  import { createI18n } from '@vielzeug/lingua';
@@ -18899,7 +19400,7 @@ i18n.restoreState(window.__I18N__);
18899
19400
  // Catalogs from state are immediately available; no network request needed.
18900
19401
  ```
18901
19402
 
18902
- `restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once. The free functions `serializeI18n()` and `hydrateI18n()` are equivalent alternatives.
19403
+ `restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once.
18903
19404
 
18904
19405
  **Warning:** `getState()` silently omits locales registered as async loaders but not yet preloaded. Use `isLoaded()` to guard:
18905
19406
 
@@ -19044,12 +19545,12 @@ router.subscribe(() => {
19044
19545
  - Keep translation keys flat or one level deep — deeply nested keys are harder to refactor.
19045
19546
  - Set `fallback` to a locale with 100% coverage so missing keys degrade gracefully.
19046
19547
  - Use `extend(ns, factory, locale?)` or `registerNamespace()` + `loadNamespace()` for per-route or per-feature key sets.
19047
- - Use `isLoaded(locale)` before `getState()` / `serializeI18n()` in SSR to avoid silently omitting async-loader locales.
19548
+ - Use `isLoaded(locale)` before `getState()` in SSR to avoid silently omitting async-loader locales.
19048
19549
  - Use `isRegistered(locale)` to check if a locale is configured; use `isLoaded(locale)` to check if it is ready.
19049
19550
  - Call `dispose()` on route-level or request-scoped `fork()` instances when they are no longer needed.
19050
19551
  - Use `{ signal }` in `subscribe()` for lifecycle-safe subscriptions; use the returned `Unsubscribe` otherwise.
19051
19552
  - Use `onMissingKey` and `onMissingVar` in development to surface authoring errors early; omit them in production.
19052
- - Import `validateCatalog` from `@vielzeug/lingua/validate` in CI only never in application code.
19553
+ - `createI18n()` already validates plural forms automatically in dev builds — only import `validateCatalog` from `@vielzeug/lingua/validate` directly if you want CI to fail the build on a warning.
19053
19554
  - Share one `i18n` instance per app entry point; avoid creating separate instances per component.
19054
19555
 
19055
19556
  ### Examples
@@ -19083,7 +19584,7 @@ router.subscribe(() => {
19083
19584
  - Pluralization Rules (id: `pluralization`)
19084
19585
  - Preload Pattern (id: `preload-pattern`)
19085
19586
  - scope(), has(), isLoaded(), extend() (id: `scope-bind`)
19086
- - serializeI18n() / hydrateI18n() — SSR hydration (id: `ssr-hydration`)
19587
+ - getState() / restoreState() — SSR hydration (id: `ssr-hydration`)
19087
19588
  - createFormatter() — standalone (no createI18n) (id: `standalone-formatter`)
19088
19589
  - Variable Interpolation (id: `variable-interpolation`)
19089
19590
 
@@ -20913,7 +21414,7 @@ define('x-tooltip', {
20913
21414
 
20914
21415
  **Category:** ui-primitives
20915
21416
  **Keywords:** web-components, custom-elements, reactive, templates, signals, lifecycle
20916
- **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+10 more)
21417
+ **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+15 more)
20917
21418
  **Related:** ripple, refine, orbit
20918
21419
 
20919
21420
  ### Overview
@@ -21035,9 +21536,9 @@ define('my-counter', {
21035
21536
 
21036
21537
  | Import | Purpose |
21037
21538
  | --------------------------- | ----------------------------------------------------------------------------- |
21038
- | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context) |
21539
+ | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context), plus the everyday template directives (`each`, `when`, `model`, `classMap`, `styleMap`) |
21039
21540
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) |
21040
- | `@vielzeug/ore/directives` | `each`, `when`, `model`, `live`, `raw`, `classMap`, `styleMap` |
21541
+ | `@vielzeug/ore/directives` | The advanced/niche directives (`live`, `raw`) and the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
21041
21542
  | `@vielzeug/ore/forms` | `useField`, `createFormContext`, `FORM_CONTEXT_KEY` |
21042
21543
  | `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` |
21043
21544
  | `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers |
@@ -21088,9 +21589,9 @@ All symbols below (except `useField`/`createFormContext`, under `@vielzeug/ore/f
21088
21589
 
21089
21590
  | Import | Purpose |
21090
21591
  | ---------------------------- | ------------------------------------------------------------------ |
21091
- | `@vielzeug/ore` | Core authoring/runtime API |
21592
+ | `@vielzeug/ore` | Core authoring/runtime API, including the everyday template directives (`each`, `when`, `classMap`, `styleMap`, `model`) |
21092
21593
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics |
21093
- | `@vielzeug/ore/directives` | Standalone directive imports (`each`, `when`, `classMap`, ) |
21594
+ | `@vielzeug/ore/directives` | The advanced/niche directives (`raw`, `live`) plus the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
21094
21595
  | `@vielzeug/ore/forms` | Form-association helpers (`useField`, `createFormContext`) |
21095
21596
  | `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers |
21096
21597
  | `@vielzeug/ore/testing` | DOM-oriented test helpers |
@@ -21344,7 +21845,7 @@ Slot signals update reactively when assigned content changes, including when slo
21344
21845
  - `ref()` — Create a `Signal` element reference. Set to the element via `ref=` in templates.
21345
21846
  - `createId(prefix = 'id')` — Generate a unique incremental string ID (e.g. `'id-1'`, `'id-2'`). Each call returns a new ID — it does not deduplicate by prefix.
21346
21847
  - `createStableId(prefix = 'id')` — Generate a unique ID that also embeds a short random tag shared across all IDs generated in the session (e.g. `'field-a3k21'`), reducing collision risk when multiple app instances run on the same page. Like `createId()`, every call returns a new ID.
21347
- - `resetIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs.
21848
+ - `resetStableIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs. Scoped to `createStableId()` only — `createId()` has no public reset (it's for uniqueness, not cross-test determinism).
21348
21849
 
21349
21850
  ## Form-Associated API
21350
21851
 
@@ -21736,12 +22237,11 @@ define('profile-name', {
21736
22237
 
21737
22238
  ## directives
21738
22239
 
21739
- Ore includes `each`, `classMap`, `styleMap`, `when`, `live`, and `raw` import them from `@vielzeug/ore/directives`.
22240
+ Ore includes `each`, `classMap`, `styleMap`, `when`, and `model` — the everyday template directives most components need, imported directly from `@vielzeug/ore` alongside `define`/`html`. The advanced/niche ones (`live`, `raw`) live behind a separate `@vielzeug/ore/directives` import — see below.
21740
22241
 
21741
22242
  ```ts
21742
22243
  import { signal } from '@vielzeug/ripple';
21743
- import { classMap, each, styleMap, when } from '@vielzeug/ore/directives';
21744
- import { define, html } from '@vielzeug/ore';
22244
+ import { classMap, define, each, html, styleMap, when } from '@vielzeug/ore';
21745
22245
 
21746
22246
  define('task-list', {
21747
22247
  setup() {
@@ -21913,8 +22413,7 @@ define('button-wrapper', {
21913
22413
  ## slots and emits
21914
22414
 
21915
22415
  ```ts
21916
- import { when } from '@vielzeug/ore/directives';
21917
- import { define, html, useEmit, useSlots } from '@vielzeug/ore';
22416
+ import { define, html, useEmit, useSlots, when } from '@vielzeug/ore';
21918
22417
 
21919
22418
  define('card-with-footer', {
21920
22419
  setup(_props) {
@@ -26841,7 +27340,7 @@ label.dispose();
26841
27340
  - **`.replace(fn)`** — derive next state from current via a function; same-reference return is a no-op
26842
27341
  - **`.reset()`** — restore the initial state baseline
26843
27342
  - **`.lens(path)`** — cached writable `Signal` for a property or dot-path; writes produce an immutable copy
26844
- - **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`
27343
+ - **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`; import via `@vielzeug/ripple/history`
26845
27344
  - **`getDevToolsHook()`** — returns the currently installed DevTools hook, or `null`; install via `@vielzeug/ripple/devtools`
26846
27345
  - **Glitch-free propagation** — computed signals propagate in dependency order; effects always observe a consistent snapshot
26847
27346
  - **Infinite loop detection** — built-in guard against effect re-entry cycles (100 iterations default)
@@ -26885,7 +27384,7 @@ label.dispose();
26885
27384
  | `scope()` | Isolated cleanup context | Sync | Must call `scope.run()` to activate; `dispose()` is LIFO |
26886
27385
  | `debugEffect()` | Effect that logs changed sources before re-run | Sync | Sub-path only: `@vielzeug/ripple/devtools`; tree-shaken from production |
26887
27386
  | `store()` | Create object-like state container | Sync | Store is a branded signal; use `.patch()`, `.replace()`, `.reset()` |
26888
- | `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer |
27387
+ | `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Sub-path only: `@vielzeug/ripple/history`; call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer |
26889
27388
  | `installDevTools()` | Install DevTools observation hook | Sync | Sub-path only: `@vielzeug/ripple/devtools`; pass `null` to uninstall |
26890
27389
  | `getDevToolsHook()` | Return current DevTools hook | Sync | Returns `null` if none installed |
26891
27390
  | `isSignal()` | Type guard for any signal/computed/store | Sync | Uses an internal symbol marker, not duck-typing |
@@ -26898,6 +27397,7 @@ label.dispose();
26898
27397
  | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26899
27398
  | `@vielzeug/ripple` | All core exports and types |
26900
27399
  | `@vielzeug/ripple/devtools` | `installDevTools`, `debugEffect`, and hook types (`RippleDevToolsHook`, `WriteEvent`, `NamedEvent`, `DisposeEvent`, `MutateEvent`) — dev-only, tree-shaken from prod |
27400
+ | `@vielzeug/ripple/history` | `storeWithHistory` and its types (`StoreWithHistory`, `HistoryEntry`) — tree-shaken unless imported |
26901
27401
  | `@vielzeug/ripple/ssr` | SSR tracking isolation helpers (`setTrackingProvider`, `createAsyncProvider`, `withProvider`, `runWithProvider`). Node.js only — do not import in browser builds. |
26902
27402
 
26903
27403
  ## Signal Primitives
@@ -27409,6 +27909,8 @@ Creates a reactive store for the given object state. Stores accept `effect()`, `
27409
27909
 
27410
27910
  ### `storeWithHistory`
27411
27911
 
27912
+ `storeWithHistory` is exported from `@vielzeug/ripple/history`, not the main entry point. This keeps it tree-shaken from bundles that never use it — for async commands or history over anything other than a `Store`, use `@vielzeug/ledger` instead.
27913
+
27412
27914
  ```ts
27413
27915
  function storeWithHistory(
27414
27916
  storeOrInitial: Store | T,
@@ -27423,6 +27925,8 @@ The initial state is saved as the first snapshot automatically. Snapshots are de
27423
27925
  **Ownership:** when called with an initial value (`T`), the adapter creates and owns the underlying store — `dispose()` also disposes it. When called with an existing `Store`, the adapter does **not** own it — `dispose()` leaves the store alive.
27424
27926
 
27425
27927
  ```ts
27928
+ import { storeWithHistory } from '@vielzeug/ripple/history';
27929
+
27426
27930
  const editor = storeWithHistory({ text: '' }, { maxHistory: 100 });
27427
27931
 
27428
27932
  editor.patch({ text: 'hello' }); // direct — StoreWithHistory extends Store
@@ -28051,6 +28555,20 @@ batch(() => {
28051
28555
 
28052
28556
  Nested `batch()` calls merge into the outermost — only one flush occurs.
28053
28557
 
28558
+ ## Error Handling
28559
+
28560
+ Ripple has no single error-handling mode — each primitive picks the surface that fits how it's normally consumed. This is one deliberate spectrum, not three unrelated designs:
28561
+
28562
+ | Primitive | Failure surface | Why |
28563
+ | ------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
28564
+ | `signal()` / `computed()` | Throws synchronously | Sync code — the caller is already in a position to catch |
28565
+ | `effect()` | Throws synchronously (rethrown from the run) | Same as above — no async gap between cause and observation |
28566
+ | `watch()` | Throws synchronously on an invalid callback return | Programmer-error guard, not a runtime failure mode |
28567
+ | `effectAsync()` | Routes to `onError` (default: rethrown via `queueMicrotask`) | There's no synchronous caller left to catch by the time the factory rejects — needs an explicit escape hatch |
28568
+ | `resource()` | Never throws — lands in `ResourceState.status === 'error'` | Failures are render state (show an error UI), not exceptions to unwind past |
28569
+
28570
+ Rule of thumb: if you're inside a synchronous callback, ripple throws (`RippleError` subtypes — see [Errors](#errors)). If the failure only exists after an `await`, ripple gives you a place to observe it instead of throwing into a call stack that's already gone — `onError` for `effectAsync()`, `ResourceState.error` for `resource()`.
28571
+
28054
28572
  ### Usage Guide
28055
28573
 
28056
28574
  ## Basic Usage
@@ -28469,7 +28987,7 @@ effect(() => {
28469
28987
  `storeWithHistory(initial, options?)` wraps a store with snapshot-based undo/redo. Mutations do **not** automatically push snapshots — call `.push()` (or `.pushNamed(label)`) explicitly after each logical change. History navigation with `undo()` and `redo()` never re-runs logic — it replays snapshots directly.
28470
28988
 
28471
28989
  ```ts
28472
- import { storeWithHistory } from '@vielzeug/ripple';
28990
+ import { storeWithHistory } from '@vielzeug/ripple/history';
28473
28991
 
28474
28992
  const editor = storeWithHistory({ text: '', cursor: 0 }, { maxHistory: 100 });
28475
28993
 
@@ -29092,7 +29610,6 @@ effect(() => {
29092
29610
  - Scope & onCleanup (id: `scope-cleanup`)
29093
29611
  - Scope — setup shorthand (id: `scope-setup`)
29094
29612
  - Store — patch, lens & computed (id: `store-basics`)
29095
- - Store History — Undo/Redo (id: `store-history`)
29096
29613
  - Store — fine-grained lens reactivity (id: `store-lenses`)
29097
29614
  - Store - Todo List (id: `store-todo-list`)
29098
29615
  - Watch, Lens & Map (id: `watch-and-subscribe`)
@@ -34241,7 +34758,7 @@ define('virtual-list', {
34241
34758
 
34242
34759
  **Category:** data
34243
34760
  **Keywords:** pagination, filtering, sorting, search, data-source, query, remote, local, cursor, infinite-scroll
34244
- **Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, applyQuery, SourceDisposedError, SourcererError, SourceTimeoutError, sourceState, itemRange (+39 more)
34761
+ **Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, SourcererDisposedError, SourcererError, SourcererTimeoutError, sourceState, itemRange, prefetchSource (+38 more)
34245
34762
  **Related:** courier, ripple, wayfinder
34246
34763
 
34247
34764
  ### Overview
@@ -34341,6 +34858,8 @@ console.log(source.current, source.meta.totalItems);
34341
34858
  | `createCursorSource()` | Server fetch | Cursor tokens | `patch()`, `ready()`, `queryKey` |
34342
34859
  | `createInfiniteSource()` | Server fetch | Append (`loadMore`) | `patch()`, `loadedPages`, `ready()`, `queryKey` |
34343
34860
 
34861
+ `@vielzeug/sourcerer/devtools`: opt-in `debugSource()` for `console.debug` state-transition logging across any source type, tree-shaken from production.
34862
+
34344
34863
  ## Documentation
34345
34864
 
34346
34865
  - [Usage Guide](./usage.md)
@@ -34365,10 +34884,9 @@ console.log(source.current, source.meta.totalItems);
34365
34884
  | `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` |
34366
34885
  | `deriveSource()` | Create a reactive projection of another source | Sync | Derived source disposes automatically when parent disposes |
34367
34886
  | `mergeSource()` | Combine multiple sources into one `MergedSource` | Sync | No `meta` field — returned type is `MergedSource`, not `ReactiveSource` |
34368
- | `applyQuery()` | Apply a partial query patch to any source with `patch()` — fires one fetch | Async | Ignores `page` on Cursor/InfiniteSource — no page concept there |
34369
34887
  | `SourcererError` | Base error class for all sourcerer errors; carries `message`, `cause`, `context`, `attempt` | Class | Extends `Error`; access context via getters, not object spread |
34370
- | `SourceTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` |
34371
- | `SourceDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourceTimeoutError` if needed |
34888
+ | `SourcererTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` |
34889
+ | `SourcererDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourcererTimeoutError` if needed |
34372
34890
  | `sourceState()` | Derive a discriminated union (`loading`/`error`/`success`) from any source | Sync | Returns `'loading'` when `isSearchPending` is true too |
34373
34891
  | `itemRange()` | Compute 1-based display range from `SourceMeta` | Sync | Returns `{ start: 0, end: 0 }` when `totalItems === 0` |
34374
34892
  | `prefetchSource()` | SSR: fetch first page, return serialisable snapshot; source is disposed immediately | Async | **Throws `SourcererError`** if fetch fails |
@@ -34384,11 +34902,12 @@ console.log(source.current, source.meta.totalItems);
34384
34902
  | `SearchOptions` | Options bag for `search()` — only field is `immediate?: boolean` | Type | `search()` always returns `Promise`; debounced unless `{ immediate: true }` |
34385
34903
  | `DecodeQueryOptions` | Options for `decodeQuery()` — `defaultLimit` and `strict` | Type | `strict: true` throws on malformed JSON; default silently drops it |
34386
34904
 
34387
- ## Package Entry Point
34905
+ ## Package Entry Points
34388
34906
 
34389
- | Import | Purpose |
34390
- | --------------------- | ---------------------- |
34391
- | `@vielzeug/sourcerer` | Main exports and types |
34907
+ | Import | Purpose |
34908
+ | -------------------------------- | ------------------------------------------------------ |
34909
+ | `@vielzeug/sourcerer` | Main exports and types |
34910
+ | `@vielzeug/sourcerer/devtools` | Opt-in `debugSource()` — tree-shaken from production |
34392
34911
 
34393
34912
  ## Core Factories
34394
34913
 
@@ -34586,7 +35105,7 @@ All methods return `Promise` unless noted.
34586
35105
  | `patch(changes)` | Apply one or more query changes atomically — a single recompute for any combination of `limit`, `page`, `search`, `filter`, `sort` |
34587
35106
  | `prev()` | Navigate to the previous page (no-op at first page) |
34588
35107
  | `query` | Current state as a `SourceQuery` (`limit`/`page`/`search` only — filter/sort aren't part of the query snapshot) — read-only snapshot; stable between changes |
34589
- | `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35108
+ | `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34590
35109
  | `reset()` | Restore initial config and return to page 1 |
34591
35110
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
34592
35111
  | `setData(data)` | Replace the dataset and reset to page 1 |
@@ -34608,7 +35127,7 @@ All methods return `Promise` except `optimisticUpdate` and `subscribe`.
34608
35127
  | `patch(changes)` | Apply one or more query changes atomically — a single fetch for any combination of `limit`, `page`, `search`, `filter`, `sort` |
34609
35128
  | `prev()` | Previous page (no-op at first page) |
34610
35129
  | `query` | Current state as a `RemoteSourceQuery` — read-only snapshot; stable between changes |
34611
- | `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35130
+ | `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34612
35131
  | `refresh()` | Re-fetch the current query |
34613
35132
  | `reset()` | Restore initial config and refetch |
34614
35133
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
@@ -34640,7 +35159,7 @@ optimisticUpdate(
34640
35159
  | `patch(changes)` | Apply `limit` and/or `search` atomically — a single fetch; resets cursor position |
34641
35160
  | `prev()` | Go back using `prevCursor` (no-op if none) |
34642
35161
  | `query` | Current state as a `CursorSourceQuery` — read-only snapshot; stable between changes |
34643
- | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35162
+ | `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34644
35163
  | `refresh()` | Re-fetch current cursor position |
34645
35164
  | `reset()` | Clear cursors and fetch from the start |
34646
35165
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await. Resets cursor position. |
@@ -34656,33 +35175,22 @@ optimisticUpdate(
34656
35175
  | `loadMore()` | Fetch the next page and append to `current` (no-op when `meta.hasMore === false`) |
34657
35176
  | `patch(changes)` | Apply `limit` and/or `search` atomically — **clears items immediately** and fetches from page 1 |
34658
35177
  | `query` | Current state as an `InfiniteSourceQuery` — read-only snapshot; stable between changes |
34659
- | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35178
+ | `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34660
35179
  | `reset()` | Clear accumulated items **immediately** and fetch from page 1 |
34661
35180
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default — **clears items immediately**; fetch fires after debounce. Pass `{ immediate: true }` to skip the window. |
34662
35181
  | `subscribe(listener)` | Subscribe; returns unsubscribe |
34663
35182
 
34664
35183
  ## Query Utilities
34665
35184
 
34666
- ### `applyQuery`
34667
-
34668
- ```ts
34669
- applyQuery): Promise }>(
34670
- source: T,
34671
- changes: Partial,
34672
- ): Promise
34673
- ```
34674
-
34675
- Applies a partial `SourceQuery` (`limit`/`page`/`search`) patch to any source that exposes a compatible `patch()` — delegates directly to `source.patch(changes)`. Fires a single fetch or recomputation for any combination of changed fields. No-op when `changes` is empty or all values are unchanged, per each source's own `patch()` implementation.
34676
-
34677
- `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation) — their `patch()` only reads `limit`/`search`, so a `page` field from `decodeQuery()` output is silently ignored on those two source types.
35185
+ Restoring URL-decoded state onto a source is a direct `source.patch(decodeQuery(...))` call — no separate wrapper function. `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation), so their `patch()` type only accepts `limit`/`search`; passing a `page` field from `decodeQuery()`'s output to either is a compile-time error, not a silent no-op.
34678
35186
 
34679
35187
  **Example:**
34680
35188
 
34681
35189
  ```ts
34682
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35190
+ import { decodeQuery } from '@vielzeug/sourcerer';
34683
35191
 
34684
35192
  const q = decodeQuery(new URLSearchParams(location.search));
34685
- await applyQuery(source, q);
35193
+ await source.patch(q);
34686
35194
  ```
34687
35195
 
34688
35196
  ---
@@ -34701,13 +35209,13 @@ class SourcererError extends Error {
34701
35209
  }
34702
35210
  ```
34703
35211
 
34704
- Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourceTimeoutError` and `SourceDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors.
35212
+ Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourcererTimeoutError` and `SourcererDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors.
34705
35213
 
34706
- ### `SourceTimeoutError`
35214
+ ### `SourcererTimeoutError`
34707
35215
 
34708
35216
  ```ts
34709
- class SourceTimeoutError extends SourcererError {
34710
- readonly name = 'SourceTimeoutError';
35217
+ class SourcererTimeoutError extends SourcererError {
35218
+ readonly name = 'SourcererTimeoutError';
34711
35219
  readonly timeoutMs: number;
34712
35220
  // message: 'Source.ready() timed out after Nms'
34713
35221
  }
@@ -34715,11 +35223,11 @@ class SourceTimeoutError extends SourcererError {
34715
35223
 
34716
35224
  Thrown by `ready(timeout)` when the timeout expires before the source becomes idle. Also caught by `instanceof SourcererError`.
34717
35225
 
34718
- ### `SourceDisposedError`
35226
+ ### `SourcererDisposedError`
34719
35227
 
34720
35228
  ```ts
34721
- class SourceDisposedError extends SourcererError {
34722
- readonly name = 'SourceDisposedError';
35229
+ class SourcererDisposedError extends SourcererError {
35230
+ readonly name = 'SourcererDisposedError';
34723
35231
  // message: 'Source disposed while waiting for ready()'
34724
35232
  }
34725
35233
  ```
@@ -34899,11 +35407,34 @@ Not generic — `filter`/`sort` on the result are always typed `unknown`. Narrow
34899
35407
  **Example:**
34900
35408
 
34901
35409
  ```ts
34902
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35410
+ import { decodeQuery } from '@vielzeug/sourcerer';
34903
35411
 
34904
35412
  // Pass URLSearchParams directly — filter/sort come back as `unknown`, narrow before use
34905
35413
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
34906
- await applyQuery(source, query);
35414
+ await source.patch(query);
35415
+ ```
35416
+
35417
+ ## Devtools (`@vielzeug/sourcerer/devtools`)
35418
+
35419
+ ```ts
35420
+ debugSource>(
35421
+ source: { current: readonly unknown[]; meta: TMeta; subscribe(listener: () => void): () => void },
35422
+ options?: { label?: string },
35423
+ ): () => void
35424
+ ```
35425
+
35426
+ Attaches a `console.debug`-based observer to any source (or `deriveSource`/`mergeSource` result) — logs a line per changed `meta` field (works generically across `SourceMeta`/`CursorMeta`/`InfiniteMeta`) and `current`'s item count. Development-only by default; a no-op when `__SOURCERER_PROD__` is set. Import from the dedicated `/devtools` sub-path so it's tree-shaken from production bundles.
35427
+
35428
+ ```ts
35429
+ import { createRemoteSource } from '@vielzeug/sourcerer';
35430
+ import { debugSource } from '@vielzeug/sourcerer/devtools';
35431
+
35432
+ const source = createRemoteSource({ fetch: fetchUsers, limit: 20 });
35433
+ const detach = debugSource(source, { label: 'users' });
35434
+ // [sourcerer:devtools:users] meta.isLoading: false → true
35435
+ // [sourcerer:devtools:users] current: 0 → 20 items
35436
+
35437
+ detach(); // stop logging
34907
35438
  ```
34908
35439
 
34909
35440
  ## Types
@@ -35007,50 +35538,50 @@ type FetchEvent = Readonly;
35007
35538
 
35008
35539
  See [Error Utilities > `SourcererError`](#sourcererror) above.
35009
35540
 
35010
- ### `SourceTimeoutError`
35541
+ ### `SourcererTimeoutError`
35011
35542
 
35012
35543
  ```ts
35013
- class SourceTimeoutError extends SourcererError {
35014
- readonly name = 'SourceTimeoutError';
35544
+ class SourcererTimeoutError extends SourcererError {
35545
+ readonly name = 'SourcererTimeoutError';
35015
35546
  readonly timeoutMs: number;
35016
35547
  // message: 'Source.ready() timed out after Nms'
35017
35548
  }
35018
35549
  ```
35019
35550
 
35020
- Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourceTimeoutError` for typed catch blocks:
35551
+ Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourcererTimeoutError` for typed catch blocks:
35021
35552
 
35022
35553
  ```ts
35023
- import { SourceTimeoutError } from '@vielzeug/sourcerer';
35554
+ import { SourcererTimeoutError } from '@vielzeug/sourcerer';
35024
35555
 
35025
35556
  try {
35026
35557
  await source.ready(5000);
35027
35558
  } catch (err) {
35028
- if (err instanceof SourceTimeoutError) {
35559
+ if (err instanceof SourcererTimeoutError) {
35029
35560
  console.warn('Source did not load in time:', err.message);
35030
35561
  }
35031
35562
  }
35032
35563
  ```
35033
35564
 
35034
- ### `SourceDisposedError`
35565
+ ### `SourcererDisposedError`
35035
35566
 
35036
35567
  ```ts
35037
- class SourceDisposedError extends SourcererError {
35038
- readonly name = 'SourceDisposedError';
35568
+ class SourcererDisposedError extends SourcererError {
35569
+ readonly name = 'SourcererDisposedError';
35039
35570
  // message: 'Source disposed while waiting for ready()'
35040
35571
  }
35041
35572
  ```
35042
35573
 
35043
- Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourceDisposedError` to distinguish it from `SourceTimeoutError`:
35574
+ Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourcererDisposedError` to distinguish it from `SourcererTimeoutError`:
35044
35575
 
35045
35576
  ```ts
35046
- import { SourceDisposedError, SourceTimeoutError } from '@vielzeug/sourcerer';
35577
+ import { SourcererDisposedError, SourcererTimeoutError } from '@vielzeug/sourcerer';
35047
35578
 
35048
35579
  try {
35049
35580
  await source.ready(5000);
35050
35581
  } catch (err) {
35051
- if (err instanceof SourceDisposedError) {
35582
+ if (err instanceof SourcererDisposedError) {
35052
35583
  // source was torn down — skip cleanup
35053
- } else if (err instanceof SourceTimeoutError) {
35584
+ } else if (err instanceof SourcererTimeoutError) {
35054
35585
  console.warn('timed out');
35055
35586
  }
35056
35587
  }
@@ -35133,13 +35664,13 @@ await source.reset(); // restore initial filter/sort, reset to page 1
35133
35664
 
35134
35665
  ### Restoring from URL state
35135
35666
 
35136
- Use `applyQuery()` + `decodeQuery()` to restore URL-decoded state in a single atomic recompute.
35667
+ Use `decodeQuery()` + `source.patch()` to restore URL-decoded state in a single atomic recompute.
35137
35668
 
35138
35669
  ```ts
35139
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35670
+ import { decodeQuery } from '@vielzeug/sourcerer';
35140
35671
 
35141
35672
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 10 });
35142
- await applyQuery(source, query);
35673
+ await source.patch(query);
35143
35674
  ```
35144
35675
 
35145
35676
  ## Remote Source
@@ -35222,13 +35753,13 @@ await source.refresh(); // re-fetch current query
35222
35753
 
35223
35754
  ### Restoring from URL state
35224
35755
 
35225
- `applyQuery()` is a no-op when `changes` is empty — safe to call on every page load.
35756
+ `source.patch()` is a no-op when `changes` is empty — safe to call on every page load.
35226
35757
 
35227
35758
  ```ts
35228
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35759
+ import { decodeQuery } from '@vielzeug/sourcerer';
35229
35760
 
35230
35761
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
35231
- await applyQuery(source, query);
35762
+ await source.patch(query);
35232
35763
  ```
35233
35764
 
35234
35765
  ### Optimistic updates
@@ -35331,13 +35862,13 @@ await source.reset(); // clear all, restart from page 1
35331
35862
 
35332
35863
  ### Restoring from URL state
35333
35864
 
35334
- Use `applyQuery()` + `decodeQuery()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed.
35865
+ Use `decodeQuery()` + `source.patch()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed.
35335
35866
 
35336
35867
  ```ts
35337
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35868
+ import { decodeQuery } from '@vielzeug/sourcerer';
35338
35869
 
35339
35870
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
35340
- await applyQuery(source, { limit: query.limit, search: query.search });
35871
+ await source.patch({ limit: query.limit, search: query.search });
35341
35872
  ```
35342
35873
 
35343
35874
  ## Error Handling
@@ -35446,7 +35977,7 @@ const params = encodeQuery(source.query);
35446
35977
 
35447
35978
  // Restore from URLSearchParams directly
35448
35979
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
35449
- await applyQuery(source, query);
35980
+ await source.patch(query);
35450
35981
  ```
35451
35982
 
35452
35983
  `decodeQuery` is fault-tolerant by default — malformed `filter`/`sort` JSON is silently dropped. Pass `{ strict: true }` to throw instead.
@@ -35607,12 +36138,12 @@ effect(() => {
35607
36138
  - Pass the `AbortSignal` from the `fetch` callback to your HTTP client so superseded requests are cancelled.
35608
36139
  - Call `ready()` in server-side rendering or test setup — not in every render cycle.
35609
36140
  - Always call the unsubscribe function returned by `subscribe()` when the component is torn down.
35610
- - For URL sync, use `decodeQuery()` + `applyQuery()` rather than reconstructing source state from params manually.
36141
+ - For URL sync, use `decodeQuery()` + `source.patch()` rather than reconstructing source state from params manually.
35611
36142
  - Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards.
35612
36143
  - If you use `optimisticUpdate()`, call `refresh()` after mutation confirmation; it bypasses `staleTime` while optimistic state is active.
35613
36144
  - Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling.
35614
36145
  - When using `decodeQuery()`, validate the parsed `filter` and `sort` with a type guard before passing to the server — they are returned as-is without runtime validation.
35615
- - For infinite sources, pass `{ limit: query.limit, search: query.search }` to `applyQuery()` for URL state sync — `page` is not restorable since items accumulate across pages.
36146
+ - For infinite sources, pass `{ limit: query.limit, search: query.search }` to `source.patch()` for URL state sync — `page` isn't part of `InfiniteSource`'s `patch()` type at all, since items accumulate across pages rather than jumping to one.
35616
36147
 
35617
36148
  ### Examples
35618
36149
 
@@ -35639,7 +36170,7 @@ effect(() => {
35639
36170
  - LocalSource patch() with filter/sort (id: `local-source-patch`)
35640
36171
  - Presets (filterContains, filterEquals, filterRange, sortBy) (id: `presets`)
35641
36172
  - Remote Source (id: `remote-source`)
35642
- - sourceState & SourceTimeoutError (id: `source-state`)
36173
+ - sourceState & SourcererTimeoutError (id: `source-state`)
35643
36174
 
35644
36175
 
35645
36176
  ---