@vielzeug/codex 26.10.7 → 26.10.9

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 42 packages. Version: 26.10.6
3
+ > Complete documentation for 42 packages. Version: 26.10.8
4
4
 
5
5
  ---
6
6
 
@@ -105,7 +105,7 @@ console.log(parsed.entries);
105
105
  - `archiveAuthHealthSchema` + `AUTH_SCHEMA_VERSION` — typed issuer readiness checked against the issuer DDL stamp
106
106
  - `archiveErrorResponseSchema` — the one error envelope, code-typed across the wire
107
107
  - `createAuthWorker` — scanner-safe confirmation, live session-version enforcement, durable budgets, metadata export and coordinated erasure
108
- - `AUTH_ISSUER_SERVICE` — required uncached issuer check after local catalog JWT verification
108
+ - `AUTH_ISSUER_SERVICE` — signing-key retrieval and an uncached issuer check after local catalog JWT verification
109
109
  - `ArchiveDerivationError` — distinguishes faulty collection code from invalid submitted content
110
110
  - Moderation — reader reports, the operator soft-hide and the report queue, keyed on a verified `roles` claim
111
111
  - `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default
@@ -147,7 +147,7 @@ console.log(parsed.entries);
147
147
  | Import | Purpose |
148
148
  | --- | --- |
149
149
  | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
150
- | `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |
150
+ | `@vielzeug/archive/worker` | Catalog factory, issuer signing-key/session service binding and event types |
151
151
  | `@vielzeug/archive/auth` | Issuer factory and binding types |
152
152
 
153
153
  ## Collections
@@ -243,8 +243,13 @@ export default createArchiveWorker([notes]);
243
243
  | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
244
244
 
245
245
  `AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.
246
+ The catalog fetches signing keys from the configured `JWT_JWKS_URL` through that
247
+ binding, not through global `fetch`. The URL still selects the issuer's JWKS
248
+ route and must satisfy the environment's transport rules. Key requests use GET
249
+ without a session cookie and do not follow redirects.
246
250
  The catalog verifies the signed JWT locally, then asks the issuer to validate its
247
- current account session version. It does not cache successful issuer verdicts.
251
+ current account session version. JOSE caches signing keys; the catalog does not
252
+ cache successful issuer session verdicts.
248
253
  If the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding
249
254
  included), the catalog returns `503 auth-unavailable`; the timeout does not cancel
250
255
  work already started by the issuer.
@@ -404,8 +409,10 @@ clear the cookie. This revokes all sessions, including the caller's, after a
404
409
  successful response. Requests authorized before revocation may finish; there is
405
410
  no cross-database transactional or in-flight cancellation guarantee.
406
411
 
407
- The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After
408
- local JWT signature/claims verification, it requests
412
+ The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer for
413
+ both JWKS retrieval and live session validation. Signing-key retrieval uses
414
+ `JWT_JWKS_URL` through the binding, so it does not depend on a public same-zone
415
+ Worker-to-Worker fetch. After local JWT signature/claims verification, it requests
409
416
  `GET /api/v1/auth/session` through that binding on every private request. Only a
410
417
  strictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,
411
418
  provider and 5xx responses become 503 `auth-unavailable`; there is no authority
@@ -927,8 +934,8 @@ well-formed, or the route answers 503 without naming what is missing:
927
934
  | ------------------------ | ---------------------------------------------------------------- |
928
935
  | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
929
936
  | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
930
- | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |
931
- | `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |
937
+ | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Exact issuer claim, audience and issuer JWKS endpoint URL |
938
+ | `AUTH_ISSUER_SERVICE` | Required issuer service binding for signing-key retrieval and live session-version checks |
932
939
  | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
933
940
  | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
934
941
  | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
@@ -941,6 +948,14 @@ make the header lie.
941
948
  Generated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's
942
949
  structural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.
943
950
 
951
+ Signing keys are fetched from `JWT_JWKS_URL` through `AUTH_ISSUER_SERVICE`, not
952
+ global `fetch`. Point the URL at the bound issuer's `/api/v1/auth/jwks.json`
953
+ route; retain the HTTPS production or loopback HTTP local configuration rules.
954
+ This transport supports Workers on the same Cloudflare zone without a public
955
+ Worker-to-Worker fetch. JOSE caches signing keys, but every private request still
956
+ checks the current issuer session version. A signing-key provider failure is
957
+ `503 auth-unavailable`, not a reason to clear the browser's signed-in state.
958
+
944
959
  ## The auth issuer
945
960
 
946
961
  `createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless
@@ -1377,6 +1392,8 @@ console.log(byRole, health, profile);
1377
1392
  - **`tryParseJson`**: preserve JSON syntax failures from `/object`
1378
1393
  - **`allocate`**: lossless proportional distribution from `/math`
1379
1394
  - **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`
1395
+ - **`paginate`**: one page plus its totals from an in-memory list, in a one- or zero-based page base
1396
+ - **`sort`**: selector, `{ key: direction }`, or ordered grid criteria from one entry point
1380
1397
  - **`range`, `average`, `median`**: common numeric operations from `/math`
1381
1398
  - **`assert`, `tap`**: typed control-flow helpers from `/function`
1382
1399
  - **`isEqual`**: structural equality from package root
@@ -1404,6 +1421,8 @@ console.log(byRole, health, profile);
1404
1421
  | --- | --- | --- | --- |
1405
1422
  | `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |
1406
1423
  | `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |
1424
+ | `paginate` | Cut one page and its totals out of a list | Sync | Pages past the last return empty `items`, unclamped |
1425
+ | `sort` | Sort by a selector, keyed fields, or ordered path criteria | Sync | Criteria paths are resolved safely; mixed value types throw |
1407
1426
  | `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |
1408
1427
  | `assert` | Assert and narrow a condition | Sync | Available from `/function` |
1409
1428
  | `retry` | Retry async work | Async | Rethrows final error |
@@ -1424,7 +1443,7 @@ console.log(byRole, health, profile);
1424
1443
  | Import | Purpose |
1425
1444
  | --- | --- |
1426
1445
  | `@vielzeug/arsenal` | Curated common utilities |
1427
- | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |
1446
+ | `@vielzeug/arsenal/array` | Immutable transforms, pagination, set operations, sorting, fuzzy search |
1428
1447
  | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |
1429
1448
  | `@vielzeug/arsenal/cache` | In-memory cache and memoization |
1430
1449
  | `@vielzeug/arsenal/encoding` | Base64url and native byte compression |
@@ -1452,6 +1471,35 @@ unzip(rows): tupleOfArrays
1452
1471
 
1453
1472
  `take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.
1454
1473
 
1474
+ ### paginate
1475
+
1476
+ ```ts
1477
+ paginate(items: readonly T[], options?: PaginateOptions): PageEnvelope
1478
+ ```
1479
+
1480
+ Cuts one page out of an in-memory list and returns the items with the arithmetic that produced them. `pageOffset` selects the caller's page base: `1` (default) for one-based paging, `0` for zero-based servers such as Spring. The default `page` is the first page in that base, and the default `pageSize` is `20`. `totalPages` is at least 1, even for an empty list, so `page (array: readonly T[], selector: (item: T) => unknown, direction?: SortDirection): T[];
1481
+ sort(array: readonly T[], selectors: SortSelectors): T[];
1482
+ sort(array: readonly T[], criteria: ReadonlyArray>): T[];
1483
+ ```
1484
+
1485
+ Three forms, one entry point. The selector form sorts by one extracted value; the selectors form accepts an object mapping property keys to directions; the criteria form sorts by an ordered list of paths, which is the shape a data grid hands over when its sortable columns are runtime strings.
1486
+
1487
+ ```ts
1488
+ import { sort } from '@vielzeug/arsenal/array';
1489
+
1490
+ const users = [{ name: 'Ada', age: 41 }, { name: 'Grace', age: 30 }];
1491
+ sort(users, (user) => user.name);
1492
+ sort(users, { name: 'asc', age: 'desc' });
1493
+
1494
+ const rows = [
1495
+ { address: { city: 'Berlin' }, age: 41 },
1496
+ { address: { city: 'Aachen' }, age: 30 },
1497
+ ];
1498
+ sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
1499
+ ```
1500
+
1501
+ Criteria paths are resolved with `getPath`, so dotted and bracketed paths (`'address.city'`, `'tags[0]'`) work and prototype-access segments are refused. The first criterion that differs wins and earlier ties keep their input order. Ordering is delegated to `compare`, so a column that mixes value types (a number beside a string) throws a `TypeError` instead of guessing an order.
1502
+
1455
1503
  ### fuzzyFilter / fuzzyScore
1456
1504
 
1457
1505
  ```ts
@@ -1717,6 +1765,16 @@ type FuzzySelection = FuzzyOptions & {
1717
1765
 
1718
1766
  type ScoredResult = { item: T; score: number };
1719
1767
 
1768
+ type PageEnvelope = Readonly;
1769
+
1770
+ type PaginateOptions = Readonly;
1771
+
1772
+ type SortDirection = 'asc' | 'desc';
1773
+
1774
+ type SortSelectors = Partial>;
1775
+
1776
+ type SortCriterion = Readonly;
1777
+
1720
1778
  type CacheOptions = {
1721
1779
  capacity?: number;
1722
1780
  now?: () => number;
@@ -1791,6 +1849,45 @@ const byPrice = sort(products, (product) => product.price);
1791
1849
  console.log(publishedLabels, byId, byPrice);
1792
1850
  ```
1793
1851
 
1852
+ ## Page an In-Memory List
1853
+
1854
+ Use `paginate` when the whole list is local and a view needs one page plus its totals.
1855
+
1856
+ ```ts
1857
+ import { paginate } from '@vielzeug/arsenal/array';
1858
+
1859
+ const rows = Array.from({ length: 45 }, (_, index) => ({ id: index + 1 }));
1860
+
1861
+ const first = paginate(rows, { pageSize: 10 });
1862
+ const past = paginate(rows, { page: 9, pageSize: 10 });
1863
+
1864
+ console.log(first.totalPages, past.items); // 5 []
1865
+ ```
1866
+
1867
+ `page` is echoed rather than clamped, so a stale page number reads as an empty page instead of
1868
+ silently jumping elsewhere. Pass `{ pageOffset: 0 }` for a zero-based caller and the envelope comes
1869
+ back in that same base.
1870
+
1871
+ ## Sort by Grid Criteria
1872
+
1873
+ `sort` accepts the ordering a data grid already holds: an ordered list of column paths, each with a direction.
1874
+
1875
+ ```ts
1876
+ import { sort } from '@vielzeug/arsenal/array';
1877
+
1878
+ const rows = [
1879
+ { address: { city: 'Berlin' }, age: 41 },
1880
+ { address: { city: 'Aachen' }, age: 30 },
1881
+ { address: { city: 'Berlin' }, age: 27 },
1882
+ ];
1883
+
1884
+ const ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);
1885
+ ```
1886
+
1887
+ Paths are resolved safely, so nested and indexed columns work and prototype access is refused. The
1888
+ first criterion that differs decides the order and ties keep their input order. Keep each column
1889
+ typed: a column mixing numbers and strings throws a `TypeError` rather than guessing an order.
1890
+
1794
1891
  ## Search Explicit Fields
1795
1892
 
1796
1893
  Search string arrays directly. Object collections require `select`, so callers define exactly what can match.
@@ -1972,8 +2069,10 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
1972
2069
  - filterMap - Filter and map array elements (id: `array-filter`)
1973
2070
  - groupBy - Group array by key (id: `array-group`)
1974
2071
  - filterMap - Transform array elements (id: `array-map`)
2072
+ - paginate - Page an array (id: `array-paginate`)
1975
2073
  - fuzzyFilter - Explicit searchable fields (id: `array-search`)
1976
2074
  - fuzzyFilter - Unicode normalization (id: `array-search-normalize`)
2075
+ - sort - Selector, selectors, or grid criteria (id: `array-sort-criteria`)
1977
2076
  - uniq - Remove duplicates (id: `array-uniq`)
1978
2077
  - attempt - Safe async execution with isFail/isOk helpers (id: `async-attempt`)
1979
2078
  - parallel - Controlled parallel execution (id: `async-parallel`)
@@ -2072,6 +2171,8 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2072
2171
  - `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events.
2073
2172
  - `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting.
2074
2173
  - `delay` models explicit cancellable timer scheduling.
2174
+ - `walkFlatTree` traverses the flat tree across slot boundaries jsdom's light-DOM queries miss.
2175
+ - `installFormInternalsPolyfill` gives jsdom the `ElementInternals` form-association API.
2075
2176
  - `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures.
2076
2177
 
2077
2178
  ## Documentation
@@ -2098,12 +2199,14 @@ await waitUntil(() => view.queryByText('Saved') !== null);
2098
2199
  | `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior |
2099
2200
  | `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits |
2100
2201
  | `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait |
2202
+ | `walkFlatTree` | Walks the flat tree across slot boundaries | Sync | jsdom-only need: browsers already use the flat tree |
2203
+ | `installFormInternalsPolyfill` | Polyfills `ElementInternals` form association for jsdom | Sync | Global patches: only install in suites that need them |
2101
2204
 
2102
2205
  ## Package Entry Point
2103
2206
 
2104
2207
  | Import | Purpose |
2105
2208
  | ----------------- | ---------------------------------------------------- |
2106
- | `@vielzeug/assay` | DOM queries, events, wait helpers, errors, and types |
2209
+ | `@vielzeug/assay` | DOM queries, events, wait helpers, test-environment helpers, errors, and types |
2107
2210
 
2108
2211
  ## Queries
2109
2212
 
@@ -2195,6 +2298,57 @@ await delay(100, { signal });
2195
2298
 
2196
2299
  `timeout` and delay milliseconds must be finite, non-negative, and no greater than 2,147,483,647 ms; `interval` must be finite, positive, and within the same timer limit. Invalid durations throw `RangeError`. `eventually()` also accepts `message` for diagnostic timeout context.
2197
2300
 
2301
+ ## Test-environment helpers
2302
+
2303
+ ### `walkFlatTree(root, visit)`
2304
+
2305
+ ```ts
2306
+ function walkFlatTree(root: Element, visit: (element: HTMLElement) => void): void;
2307
+ ```
2308
+
2309
+ Walks the flat tree below `root`, calling `visit` for each assigned or ordinary descendant (not for `root` itself).
2310
+ At each `` it expands `assignedElements()` instead of descending into its (always empty, in jsdom) literal
2311
+ children. jsdom only
2312
+ implements the light DOM tree, so `querySelectorAll()` never crosses a slot into its assigned elements; real
2313
+ browsers use the flat tree for that. This helper closes the gap for tests that must find form-associated (or
2314
+ otherwise slotted) elements through shadow boundaries.
2315
+
2316
+ ```ts
2317
+ const named: HTMLElement[] = [];
2318
+
2319
+ walkFlatTree(form, (el) => {
2320
+ if (el.hasAttribute('name')) named.push(el);
2321
+ });
2322
+ ```
2323
+
2324
+ ### `installFormInternalsPolyfill()`
2325
+
2326
+ ```ts
2327
+ function installFormInternalsPolyfill(): () => void;
2328
+ ```
2329
+
2330
+ jsdom implements none of the `ElementInternals` form-association API: not `setFormValue`, not `setValidity`, not
2331
+ the `checkValidity`/`reportValidity`/`validity`/`validationMessage` mixin real browsers put on every
2332
+ `formAssociated: true` element, not even `FormData` collecting a form-associated element's set value, or
2333
+ `.reset()` invoking `formResetCallback()`. Every one of those gaps is exercised by `useField()` from
2334
+ `@vielzeug/ore`, so any package testing a form-associated component needs all of them.
2335
+
2336
+ Call it from a test setup when the suite tests form-associated components; it returns an `uninstall()` that
2337
+ restores every patched global. The patches are global monkey-patches and skip APIs already implemented by the
2338
+ environment, so suites without form-associated components shouldn't install them. If `ElementInternals` is absent,
2339
+ the installer is a no-op. A downstream package should
2340
+ rely on this instead of hand-rolling its own copy.
2341
+
2342
+ ```ts
2343
+ // vitest.setup.ts
2344
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2345
+ import { install } from '@vielzeug/ore/testing';
2346
+ import { afterAll, afterEach } from 'vitest';
2347
+
2348
+ install(afterEach);
2349
+ afterAll(installFormInternalsPolyfill());
2350
+ ```
2351
+
2198
2352
  ## Types
2199
2353
 
2200
2354
  ```ts
@@ -2349,6 +2503,36 @@ expect(onSave).toHaveBeenCalledOnce();
2349
2503
  Refine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers
2350
2504
  directly instead of routing generic DOM operations through another package.
2351
2505
 
2506
+ When a suite tests a form-associated custom element (one built on `useField()` from `@vielzeug/ore`), install the
2507
+ form-internals polyfill in the setup file so jsdom gains the `ElementInternals` API it lacks:
2508
+
2509
+ ```ts
2510
+ // vitest.setup.ts
2511
+ import { installFormInternalsPolyfill } from '@vielzeug/assay';
2512
+ import { install } from '@vielzeug/ore/testing';
2513
+ import { afterAll, afterEach } from 'vitest';
2514
+
2515
+ install(afterEach);
2516
+ afterAll(installFormInternalsPolyfill());
2517
+ ```
2518
+
2519
+ ## Traverse slotted content in jsdom
2520
+
2521
+ Use `walkFlatTree()` when a test must visit descendants assigned through slots. It expands each slot into its assigned
2522
+ elements, including nested slots, instead of relying on jsdom's light-DOM-only `querySelectorAll()`.
2523
+
2524
+ ```ts
2525
+ import { walkFlatTree } from '@vielzeug/assay';
2526
+
2527
+ const named: HTMLElement[] = [];
2528
+ walkFlatTree(form, (element) => {
2529
+ if (element.hasAttribute('name')) named.push(element);
2530
+ });
2531
+ ```
2532
+
2533
+ The callback receives descendants in the flat tree, not the root element or slot elements themselves. Browsers already
2534
+ use the flat tree for DOM algorithms; this helper is for test code that needs equivalent traversal in jsdom.
2535
+
2352
2536
  ## Best Practices
2353
2537
 
2354
2538
  - Scope multiple assertions with `within()` rather than repeatedly querying the document.
@@ -4523,6 +4707,7 @@ try {
4523
4707
  - **`withBearerAuth()` / `withRequestId()` / `withLogging()`**: composable middleware configured at construction.
4524
4708
  - **`tap()`**: signal-owned structured transport observation.
4525
4709
  - **Error taxonomy**: HTTP, network, timeout, abort, parse, and schema failures are distinct, actionable classes.
4710
+ - **RFC 9457 problem details**: an `application/problem+json` error body parses onto `CourierHttpError.problem` while the HTTP status stays authoritative.
4526
4711
  - **`cancelAll()` / `dispose()`**: abort active requests and tear down the transport.
4527
4712
 
4528
4713
  ## Documentation
@@ -4554,6 +4739,9 @@ try {
4554
4739
  | `withRequestId()` | Adds request identifier middleware | Sync | Default generator uses `crypto.randomUUID()` |
4555
4740
  | `withLogging()` | Logs request result metadata | Sync | Requires explicit logger; URLs may contain sensitive query values |
4556
4741
  | `tap()` | Observe structured transport events | Sync | Observation only; optional signal owns lifetime |
4742
+ | `CourierHttpError.problem` | Parsed RFC 9457 problem details from an error body | Sync | Present only when the response is `application/problem+json` |
4743
+ | `ProblemDetail` | RFC 9457 problem members and extension members | Sync | Extension values are `unknown`; Courier does not validate their shapes |
4744
+ | `problemDetailError()` | Build an HTTP error that carries problem details | Sync | Intended for mocks and middleware, not for parsing a response |
4557
4745
 
4558
4746
  ## Package Entry Point
4559
4747
 
@@ -4799,7 +4987,7 @@ type PrefetchConfig = Omit,
4799
4987
  | Error | Trigger | Notable properties |
4800
4988
  | --- | --- | --- |
4801
4989
  | `CourierError` | Base class for all Courier errors | Use `instanceof` to narrow |
4802
- | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`; `CourierHttpError.is(e, status?)` narrows by status |
4990
+ | `CourierHttpError` | Non-2xx HTTP response | `status`, `data`, `headers`, `method`, `url`, `problem`; `CourierHttpError.is(e, status?)` narrows by status |
4803
4991
  | `CourierNetworkError` | Request failure without response | `method`, `url`, `cause` |
4804
4992
  | `CourierTimeoutError` | Timeout signal aborts request | `method`, `url`, `cause` |
4805
4993
  | `CourierAbortError` | Caller or client cancellation | `method`, `url`, `cause` |
@@ -4810,6 +4998,72 @@ type PrefetchConfig = Omit,
4810
4998
 
4811
4999
  Errors thrown directly by custom middleware are preserved rather than reclassified.
4812
5000
 
5001
+ ### Problem details
5002
+
5003
+ ```ts
5004
+ export type ProblemDetail = Readonly;
5005
+
5006
+ export const PROBLEM_JSON_MEDIA_TYPE = 'application/problem+json';
5007
+
5008
+ export function isProblemMediaType(contentType: string | null | undefined): boolean;
5009
+
5010
+ export function problemDetailError(
5011
+ problem: ProblemDetail,
5012
+ options?: { message?: string; method?: string; status?: number; url?: string },
5013
+ ): CourierHttpError;
5014
+ ```
5015
+
5016
+ When a non-2xx response answers with `application/problem+json` (RFC 9457), `CourierHttpError.problem`
5017
+ holds a shallow-frozen copy of the parsed JSON object and the error message prefers `detail`, then
5018
+ `title`, then the response status text (or `HTTP ` when status text is empty). Any other error
5019
+ body keeps the shape it already had on `data`, with `problem` left `undefined`. Courier checks that
5020
+ the parsed body is an object; it does not validate the types or contents of registered or extension
5021
+ members. RFC 9457 treats an omitted `type` as `about:blank`, but Courier does not insert that value
5022
+ into the object.
5023
+
5024
+ | Member | Purpose | Common gotcha |
5025
+ | --- | --- | --- |
5026
+ | `ProblemDetail` | The five registered RFC 9457 members plus extensions | Every member is optional; extension values have type `unknown` |
5027
+ | `CourierHttpError.problem` | Parsed problem document | For response errors, it is a frozen shallow copy and is `undefined` unless the media type matches and the body is an object |
5028
+ | `PROBLEM_JSON_MEDIA_TYPE` | Registered media type constant | Compare with `isProblemMediaType()`, not `===`, to allow parameters |
5029
+ | `isProblemMediaType()` | Match `application/problem+json` and its parameterised forms | Plain `application/json` never matches |
5030
+ | `problemDetailError()` | Build a problem-carrying `CourierHttpError` without a server | Status defaults to numeric `problem.status`, then `500`; a supplied `options.status` takes precedence |
5031
+
5032
+ ```ts
5033
+ problemDetailError(problem, options?)
5034
+ ```
5035
+
5036
+ `problemDetailError()` returns a `CourierHttpError` with the supplied problem object on both
5037
+ `error.problem` and `error.data`. Its defaults are `method: 'GET'`, `url: ''`, and status from
5038
+ `problem.status` when numeric or `500` otherwise. The error message uses `options.message`, then
5039
+ `problem.detail`, `problem.title`, and finally `HTTP `. Unlike a parsed response problem,
5040
+ the supplied object is not copied or frozen.
5041
+
5042
+ The HTTP status line stays authoritative: `error.status` is the response status even when
5043
+ `problem.status` disagrees. A JSON array or primitive leaves `data` as that parsed value; malformed
5044
+ JSON leaves `data` as its raw text. Neither produces a partial `problem`.
5045
+
5046
+ ```ts
5047
+ import { createCourier, CourierHttpError, problemDetailError, type ProblemDetail } from '@vielzeug/courier';
5048
+
5049
+ const courier = createCourier({ baseUrl: '/api' });
5050
+
5051
+ try {
5052
+ await courier.post('/invoices');
5053
+ } catch (error) {
5054
+ if (CourierHttpError.is(error)) {
5055
+ const problem: ProblemDetail | undefined = error.problem;
5056
+ console.log(error.status, problem?.title, problem?.detail, problem?.type);
5057
+ }
5058
+ }
5059
+
5060
+ const mockedError = problemDetailError(
5061
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5062
+ { method: 'POST', url: '/invoices' },
5063
+ );
5064
+ console.log(mockedError.status, mockedError.problem?.detail);
5065
+ ```
5066
+
4813
5067
  ### Usage Guide
4814
5068
 
4815
5069
  ## Basic Usage
@@ -4998,6 +5252,55 @@ async function loadUser(): Promise {
4998
5252
  - Do not display cancellation as an application error during navigation.
4999
5253
  - `CourierSchemaValidationError` carries the raw pre-validation `data`.
5000
5254
 
5255
+ ## Problem Details
5256
+
5257
+ When a server answers a failure with `application/problem+json` (RFC 9457), Courier parses the
5258
+ JSON object onto `CourierHttpError.problem` and chooses the message from `detail`, then `title`,
5259
+ then the HTTP status text. The parsed object is shallow-frozen; extension members remain `unknown`
5260
+ and Courier does not validate their contents. Nothing is configured.
5261
+
5262
+ ```ts
5263
+ import { CourierHttpError, createCourier, type ProblemDetail } from '@vielzeug/courier';
5264
+
5265
+ const courier = createCourier({ baseUrl: '/api' });
5266
+
5267
+ async function submitInvoice(body: unknown): Promise {
5268
+ try {
5269
+ await courier.post('/invoices', { body });
5270
+ return 'Saved.';
5271
+ } catch (error) {
5272
+ if (!CourierHttpError.is(error)) throw error;
5273
+
5274
+ const problem = error.problem;
5275
+ if (problem?.title === 'Insufficient credit') return `Blocked: ${problem.detail ?? 'no credit'}`;
5276
+
5277
+ // Extension members are ordinary properties on the same document.
5278
+ const field = problem?.['errors'] as Record | undefined;
5279
+
5280
+ return field ? Object.values(field).join(', ') : `Request failed (${error.status}).`;
5281
+ }
5282
+ }
5283
+ ```
5284
+
5285
+ `error.status` is always the HTTP status from the response, so a `problem.status` that disagrees
5286
+ never overrides it. `problem` stays `undefined` for any other content type, and a `problem+json`
5287
+ body that is a JSON array or primitive keeps that parsed value on `data`; malformed JSON keeps its
5288
+ raw text there. Neither case creates a partial `problem`. RFC 9457 treats an omitted `type` as
5289
+ `about:blank`, but Courier does not add a `type` property. Use `problemDetailError()` to raise the
5290
+ same error shape from a mock or middleware; it stores the supplied problem object directly on both
5291
+ `problem` and `data`.
5292
+
5293
+ ```ts
5294
+ import { problemDetailError } from '@vielzeug/courier';
5295
+
5296
+ const mockFailure = problemDetailError(
5297
+ { title: 'Insufficient credit', detail: 'Choose a lower amount.', status: 402 },
5298
+ { method: 'POST', url: '/invoices' },
5299
+ );
5300
+
5301
+ console.log(mockFailure.status, mockFailure.problem?.detail);
5302
+ ```
5303
+
5001
5304
  ## Composing with a State Layer
5002
5305
 
5003
5306
  Courier owns HTTP transport and optional parsed GET caching. Observable query state, optimistic mutations, collection navigation, and stream state belong to the owning state layer (e.g. Sourcerer, TanStack Query, or consumer code). Compose Courier calls into that layer instead of nesting state machines.
@@ -5116,6 +5419,7 @@ There is no `use()` method. To change middleware, create a new client. This keep
5116
5419
  - Cached Read and Prefetch (id: `cached-read`)
5117
5420
  - createCourier - Transport Client (id: `create-courier`)
5118
5421
  - middleware - Immutable Chain (id: `middleware`)
5422
+ - problem+json - RFC 9457 error bodies (id: `problem-details`)
5119
5423
 
5120
5424
  ---
5121
5425
 
@@ -15548,7 +15852,7 @@ define('my-counter', {
15548
15852
  - Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects
15549
15853
  - `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
15550
15854
  - Lifecycle hooks: `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`: plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
15551
- - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `live`, `unsafeHtml`
15855
+ - Directives: `each` (transactional keyed list reconciliation), `classMap`, `styleMap`, `when`, `live`, `model`, `unsafeHtml`
15552
15856
  - Host bindings via `bind({ attr, class, style, on })`: pass `{ target: el }` to bind any off-host element
15553
15857
  - Reactive ARIA sync via explicit `aria-*` keys in `bind({ attr }, { target })`, with automatic cleanup on disconnect
15554
15858
  - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
@@ -15596,6 +15900,7 @@ All browser-runtime symbols below are imported from `@vielzeug/ore`. Lifecycle/c
15596
15900
  | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys report `ore:error`; plain `T[]` is a one-time static render |
15597
15901
  | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
15598
15902
  | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
15903
+ | `model(signal)` | Two-way form-control binding (value/checked + write-back) | Sync | Plain `value`/`checked` attribute only; pass `get` to keep numeric signals numeric |
15599
15904
  | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
15600
15905
  | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
15601
15906
  | `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
@@ -15710,12 +16015,13 @@ setup(_props) {
15710
16015
  | `prop.string(defaultValue?)` | `PropDef` | Reflects by default |
15711
16016
  | `prop.bool(defaultValue?)` | `PropDef` | Any non-null attribute value other than `"false"` parses as `true`; `"false"` or absent attribute is `false` |
15712
16017
  | `prop.number(defaultValue?)` | `PropDef` | Returns default (not NaN) and warns in dev when attribute is not a valid number |
15713
- | `prop.oneOf(allowed, defaultValue)` | `PropDef` | Restricts to provided string union |
16018
+ | `prop.oneOf(allowed, defaultValue?)` | `PropDef` | Restricts to a fixed set of string or number members; `parse` falls back to `defaultValue` (or `undefined` when omitted) for anything outside the set. Numeric members match the attribute parsed as a number |
15714
16019
  | `prop.json(defaultValue)` | `PropDef` | JSON.parse from attribute; `reflect: false` |
15715
16020
  | `prop.data(defaultValue?)` | `PropDef` | JS-only: never reads/writes an attribute; use for objects, arrays, callbacks, or any non-serialisable value |
15716
16021
 
15717
16022
  > **Choosing the right prop helper:**
15718
16023
  >
16024
+ > - **`prop.oneOf`**: the honest way to type a union-valued prop: the restriction is enforced at runtime (`parse` falls back for anything outside the set), not just in a type parameter a raw attribute value could violate. Pass an `as const` array of the allowed string or number members.
15719
16025
  > - **`prop.json`**: value can be declared in HTML (``); attribute string is `JSON.parse`d.
15720
16026
  > - **`prop.data`**: value is always set from JavaScript (objects, arrays, callbacks, class instances); the attribute is never read. Use this for both data and function props.
15721
16027
 
@@ -15748,7 +16054,16 @@ define('data-grid', {
15748
16054
  ### `html`
15749
16055
 
15750
16056
  Tagged template literal that returns an `HTMLResult`. Supports text interpolation, ordinary attributes (`attr=`),
15751
- boolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.
16057
+ boolean attributes (`?attr=`), explicit property bindings (`.prop=`), events (`@event=` with optional
16058
+ `.stop`/`.prevent`/`.once`/`.capture` modifiers), refs (`ref=`), and nested templates.
16059
+
16060
+ `.prop=${value}` always writes the element property (never an attribute): the explicit channel for structured
16061
+ values (arrays, objects, callbacks). Prototype-polluting names (`__proto__`, `constructor`, `prototype`) are
16062
+ rejected at compile time.
16063
+
16064
+ `@event.stop`, `.prevent`, `.once`, and `.capture` are shorthand for the matching listener behavior
16065
+ (`stopPropagation`/`preventDefault` applied before the handler runs; `once`/`capture` passed as listener
16066
+ options). An unknown modifier throws at compile time.
15752
16067
 
15753
16068
  ### `css`
15754
16069
 
@@ -15763,9 +16078,28 @@ Tagged template literal that returns a `CSSResult` for use in `styles`.
15763
16078
  | `classMap(record)` | Reactive class string from object map |
15764
16079
  | `styleMap(record)` | Reactive inline style string from object map |
15765
16080
  | `live(signal)` | One-way binding that skips stale writes during active user input; use with `@input` handler |
16081
+ | `model(signal, options?)` | Two-way form-control binding: writes the signal to `value`/`checked` and writes user input back on the control's change event |
15766
16082
  | `unsafeHtml(value)` | HTML rendering sink; sanitize untrusted values before calling |
15767
16083
 
15768
- `each()` treats number and string keys as distinct. A duplicate-key update reports `ore:error` and preserves the last valid list.
16084
+ `each()` treats number and string keys as distinct. A reconciliation failure (a duplicate key, or a render function that throws) reports `ore:error` and preserves the last valid list: entries created during the failed pass are rolled back, so the previous list stays exactly on screen.
16085
+
16086
+ ### `model(signal, options?)`
16087
+
16088
+ Two-way binding for a form control: `value=${model(signal)}`. It writes the signal to the control's `value`/`checked` property on every change and writes user input back into the signal on the control's change event (`'change'` for checkboxes, radios, and ``; `'input'` otherwise). This is the complete controlled-input loop that the `live()` + manual `@input` handler pair used to require.
16089
+
16090
+ ```ts
16091
+ const text = signal('');
16092
+ const agree = signal(false);
16093
+ const count = signal(0);
16094
+
16095
+ html`
16096
+
16097
+
16098
+ Number(el.value) })} />
16099
+ `;
16100
+ ```
16101
+
16102
+ `options` overrides the defaults for non-native controls: `event` sets the write-back event name and `get` reads the current value out of the element (default `el.checked` for checkboxes/radios, `el.value` otherwise). Because a native text input's `value` is always a string, pass a `get` like `(el) => Number(el.value)` to keep a numeric signal numeric. `model()` can only drive a plain `value`/`checked` attribute binding; using it on a boolean (`?checked=`) or other attribute throws.
15769
16103
 
15770
16104
  ### `unsafeHtml`
15771
16105
 
@@ -15865,6 +16199,8 @@ Import from `@vielzeug/ore`.
15865
16199
  Wire a form-associated element to `ElementInternals`. Requires `formAssociated: true` on the component definition. The `disabled` state tracking via `internals.states` (CustomStateSet) is skipped with a dev warning if the API is unavailable in the current environment.
15866
16200
 
15867
16201
  ```ts
16202
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16203
+
15868
16204
  type FormFieldOptions = {
15869
16205
  disabled?: Readable;
15870
16206
  /**
@@ -15877,8 +16213,13 @@ type FormFieldOptions = {
15877
16213
  /** Called when the ancestor resets (see onFormReset): restore local field state here. */
15878
16214
  onReset?: () => void;
15879
16215
  toFormValue?: (value: T) => File | FormData | string | null;
15880
- /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
15881
- validationMessage?: Readable;
16216
+ /**
16217
+ * Recomputed reactively and passed straight to internals.setValidity().
16218
+ * null (or omitted) = always valid. Flags and message travel together because
16219
+ * the platform's setValidity() contract couples them (flags without a
16220
+ * non-empty message throw); a single { flags, message } | null input makes a
16221
+ * desynced pair unrepresentable.
16222
+ */
15882
16223
  validity?: Readable;
15883
16224
  value: Signal | Readable;
15884
16225
  };
@@ -15892,15 +16233,20 @@ type FormFieldHandle = {
15892
16233
  };
15893
16234
  ```
15894
16235
 
15895
- Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation
15896
- through `checkValidity()` and `reportValidity()`:
16236
+ Pass a single `validity` signal to make `required`-style constraints participate in native constraint validation
16237
+ through `checkValidity()` and `reportValidity()`. The `{ flags, message }` pair is the one validity input: the old
16238
+ split `validity` + `validationMessage` options are gone, since they could drift apart into a state the platform
16239
+ rejects.
15897
16240
 
15898
16241
  ```ts
15899
16242
  const isBlank = (v: string) => v.trim() === '';
15900
16243
 
15901
16244
  useField({
15902
- validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
15903
- validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
16245
+ validity: computed(() =>
16246
+ required.value && isBlank(value.value)
16247
+ ? { flags: { valueMissing: true }, message: 'This field is required.' }
16248
+ : null,
16249
+ ),
15904
16250
  value,
15905
16251
  });
15906
16252
  ```
@@ -15913,8 +16259,7 @@ Import from `@vielzeug/ore/testing`.
15913
16259
  | ------------------------ | ------------------------------------------------------------------------------------------ |
15914
16260
  | `mount(setup, options?)` | Mount a component and return a test fixture |
15915
16261
  | `cleanup()` | Remove all mounted elements and reset test state |
15916
- | `install(afterEach, options?)` | Register auto-cleanup; pass `{ formInternals: true }` to also install the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below) |
15917
- | `installFormInternalsPolyfill()` | Installs the form-internals polyfill directly (returns an `uninstall()` that restores every patched global). Usually called via `install(afterEach, { formInternals: true })` |
16262
+ | `install(afterEach)` | Register auto-cleanup after each test |
15918
16263
  | `flush(options?)` | Drain reactive updates and animation frames |
15919
16264
  | `renderHook(setup)` | Run lifecycle hooks in isolation; overload accepts `propDefs` as first arg for typed props |
15920
16265
  | `OreTimeoutError` | Error thrown when `flush()` cannot settle tracked Ore work |
@@ -15924,7 +16269,16 @@ Import from `@vielzeug/ore/testing`.
15924
16269
  Import `within`, named dispatchers such as `fireClick`, and waits such as `waitUntil` or `waitForEvent` from
15925
16270
  `@vielzeug/assay`.
15926
16271
 
15927
- > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API: `install(afterEach, { formInternals: true })` polyfills `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already exists, and `installFormInternalsPolyfill()` returns an `uninstall()` that restores every patched global. The polyfill is opt-in (`{ formInternals: true }`) because the patches are global: suites without form-associated components shouldn't carry them. A downstream package (e.g. a component library built on `ore`) should rely on this instead of hand-rolling its own copy.
16272
+ > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API.
16273
+ > `@vielzeug/assay` exports `installFormInternalsPolyfill()` for this: it polyfills
16274
+ > `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`validity`/`states`, mixes
16275
+ > `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do
16276
+ > this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and
16277
+ > makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already
16278
+ > exists, and the function returns an `uninstall()` that restores every patched global. Call it directly from a
16279
+ > test setup (alongside `install(afterEach)`) only when the suite tests form-associated components: the patches
16280
+ > are global, so suites without them shouldn't carry them. A downstream package (e.g. a component library built
16281
+ > on `ore`) should rely on it instead of hand-rolling its own copy.
15928
16282
 
15929
16283
  #### `Fixture` interface
15930
16284
 
@@ -16093,18 +16447,26 @@ type CSSResult = {
16093
16447
 
16094
16448
  type LiveBinding = { readonly source: Readable };
16095
16449
 
16450
+ type ModelOptions = {
16451
+ /** Write-back event (default: 'change' for checkbox/radio/select, 'input' otherwise). */
16452
+ event?: string;
16453
+ /** Value reader (default: el.checked for checkbox/radio, el.value otherwise). */
16454
+ get?: (el: HTMLElement) => T;
16455
+ };
16456
+
16096
16457
  type EmitFn> = {
16097
16458
  >(event: K): boolean;
16098
16459
  >>(event: K, detail: T[K]): boolean;
16099
16460
  };
16100
16461
  // KeysWithoutDetail is an internal helper type, not exported.
16101
16462
 
16463
+ type FormFieldValidity = { flags: ValidityStateFlags; message: string };
16464
+
16102
16465
  type FormFieldOptions = {
16103
16466
  disabled?: Readable;
16104
16467
  emptyStringForNull?: boolean;
16105
16468
  onReset?: () => void;
16106
16469
  toFormValue?: (value: T) => File | FormData | string | null;
16107
- validationMessage?: Readable;
16108
16470
  validity?: Readable;
16109
16471
  value: Signal | Readable;
16110
16472
  };
@@ -16258,11 +16620,13 @@ define('x-button', {
16258
16620
 
16259
16621
  ## template bindings
16260
16622
 
16261
- `html` supports text, attributes, booleans, properties, events, refs, and nested templates.
16623
+ `html` supports text, attributes, booleans, properties, events, refs, and nested templates. Event bindings accept
16624
+ `.stop`, `.prevent`, `.once`, and `.capture`: the first two apply before the handler runs, while the latter two use
16625
+ native listener options. Unknown modifiers throw while Ore compiles the template.
16262
16626
 
16263
16627
  ```ts
16264
16628
  import { computed, signal } from '@vielzeug/ripple';
16265
- import { define, html, ref } from '@vielzeug/ore';
16629
+ import { define, html, model, ref } from '@vielzeug/ore';
16266
16630
 
16267
16631
  define('profile-name', {
16268
16632
  setup() {
@@ -16271,10 +16635,7 @@ define('profile-name', {
16271
16635
 
16272
16636
  return html`
16273
16637
  'Current: ' + name.value)}>Name
16274
- 'Current name ' + name.value}
16275
- @input=${(event: Event) => {
16276
- name.value = (event.target as HTMLInputElement).value;
16277
- }} />
16638
+ 'Current name ' + name.value} />
16278
16639
  Hello ${name}
16279
16640
  `;
16280
16641
  },
@@ -16283,8 +16644,7 @@ define('profile-name', {
16283
16644
 
16284
16645
  ## directives
16285
16646
 
16286
- Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, and `unsafeHtml` from `@vielzeug/ore`. Use ordinary
16287
- attribute bindings plus native event handlers for two-way input state; no special model directive is required.
16647
+ Ore exports `each`, `classMap`, `styleMap`, `when`, `live`, `model`, and `unsafeHtml` from `@vielzeug/ore`.
16288
16648
 
16289
16649
  ```ts
16290
16650
  import { signal } from '@vielzeug/ripple';
@@ -16332,9 +16692,50 @@ each(
16332
16692
  );
16333
16693
  ```
16334
16694
 
16335
- ## live form bindings
16695
+ Each reactive reconciliation is transactional. Ore creates missing entries before committing updates or removing stale
16696
+ entries; if a render callback throws, entries created during that pass are rolled back and the last successful list
16697
+ remains on screen. Ore reports the failure through `ore:error` with phase `each-reconcile`.
16336
16698
 
16337
- Use `live(signal)` for inputs that should preserve in-progress user edits instead of overwriting the DOM on stale writes.
16699
+ ## model: two-way form bindings
16700
+
16701
+ Use `model(signal)` for a controlled input: it writes the signal to the control's `value`/`checked` property and
16702
+ writes user input back on the control's change event (`'change'` for checkboxes, radios, and selects; `'input'`
16703
+ otherwise). This replaces the `live()` + manual `@input` handler pair with one binding.
16704
+
16705
+ ```ts
16706
+ import { signal } from '@vielzeug/ripple';
16707
+ import { define, html, model } from '@vielzeug/ore';
16708
+
16709
+ define('live-search', {
16710
+ setup() {
16711
+ const query = signal('');
16712
+ const agree = signal(false);
16713
+
16714
+ return html`
16715
+
16716
+
16717
+ `;
16718
+ },
16719
+ });
16720
+ ```
16721
+
16722
+ A native text input's `value` is always a string, so pass a `get` reader to keep a numeric signal numeric, and an
16723
+ `event` name for custom controls that emit their own change event:
16724
+
16725
+ ```ts
16726
+ const count = signal(0);
16727
+
16728
+ html`
16729
+ Number(el.value) })} />
16730
+ (el as MyPicker).selection })} />
16731
+ `;
16732
+ ```
16733
+
16734
+ ## live: one-way bindings that preserve user edits
16735
+
16736
+ Use `live(signal)` when a binding must skip a stale app-state write while the user is mid-edit, without writing
16737
+ user input back. `model()` covers the common controlled-input case; `live()` is for one-way bindings that only
16738
+ need the stale-write guard.
16338
16739
 
16339
16740
  ```ts
16340
16741
  import { signal } from '@vielzeug/ripple';
@@ -16698,7 +17099,7 @@ define('signup-form', {
16698
17099
  - Bind host attributes and classes via `bind()` rather than mutating the element directly.
16699
17100
  - Provide context at the nearest ancestor: avoid global context singletons.
16700
17101
  - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
16701
- - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
17102
+ - Use `model(signal)` for two-way form inputs; use `live(signal)` for a one-way binding that must not clobber user-in-progress edits.
16702
17103
  - Extract composable helper functions freely: `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
16703
17104
  - Test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic DOM events, queries, and waits
16704
17105
  from `@vielzeug/assay`.
@@ -20417,6 +20818,7 @@ Classification guides discovery and bundle selection. It is not a reason to remo
20417
20818
  - **Framework agnostic**: works anywhere HTML can be rendered
20418
20819
  - **Tree-shakeable**: import only the component entry points you register
20419
20820
  - **Comprehensive surface**: inputs, content, disclosure, feedback, layout, and overlay primitives
20821
+ - **Native form integration**: form-associated controls submit values, validate supported required constraints, and reset with their form
20420
20822
  - **Typed framework declarations**: DOM, React, and Vue tags derive from the component property contracts
20421
20823
 
20422
20824
  ### Prerequisites
@@ -20520,6 +20922,14 @@ The declarations cover every supported tag and derive component properties from
20520
20922
 
20521
20923
  Form controls expose `.value` or `.checked` and dispatch standard `input` and `change` events. Read the property from `event.currentTarget`.
20522
20924
 
20925
+ Finite-choice properties use Ore's `prop.oneOf()` parsing: values outside a component's documented options resolve to
20926
+ the declared default, or `undefined` when no default is declared.
20927
+
20928
+ Form-associated controls participate in native form submission, validity checks, and reset. A named control's value
20929
+ is available through `new FormData(form)`, and supported `required` constraints affect `form.checkValidity()`,
20930
+ `form.reportValidity()`, and submit blocking. The `error` property renders and announces feedback; it does not by
20931
+ itself set native constraint validity.
20932
+
20523
20933
  Stateful overlays expose controlled `open`, optional `default-open`, and an `open-change` custom event with `{ open, reason }` detail. Component pages define additional detail fields and reasons.
20524
20934
 
20525
20935
  ## Utilities
@@ -20625,6 +21035,20 @@ grid.rows = [
20625
21035
  ];
20626
21036
  ```
20627
21037
 
21038
+ Enumerated properties such as `variant`, `size`, and `color` accept the values listed on each component's reference
21039
+ page. An unsupported value resolves to the property's declared default (or `undefined` when it has no default).
21040
+
21041
+ In an Ore template, `model()` can bind a Refine field's `value` to a Ripple signal and write user input back:
21042
+
21043
+ ```ts
21044
+ import { signal } from '@vielzeug/ripple';
21045
+ import { html, model } from '@vielzeug/ore';
21046
+ import '@vielzeug/refine/input';
21047
+
21048
+ const email = signal('');
21049
+ const field = html``;
21050
+ ```
21051
+
20628
21052
  ## Handle Events
20629
21053
 
20630
21054
  Form controls dispatch standard `input` and `change` events. Read their current property from `event.currentTarget`.
@@ -20648,6 +21072,37 @@ dialog?.addEventListener('open-change', (event: CustomEvent) => {
20648
21072
  });
20649
21073
  ```
20650
21074
 
21075
+ ## Native Form Validation
21076
+
21077
+ Refine's form-associated controls integrate with native form submission, `FormData`, validity checks, and reset.
21078
+ Use `required` for a required-value constraint; setting `error` displays and announces feedback but does not, by
21079
+ itself, invalidate the form.
21080
+
21081
+ ```html
21082
+
21083
+
21084
+
21085
+ Choose a role…
21086
+ Editor
21087
+ Admin
21088
+
21089
+ Save
21090
+
21091
+ import '@vielzeug/refine/input';
21092
+ import '@vielzeug/refine/select';
21093
+ import '@vielzeug/refine/button';
21094
+
21095
+ const form = document.querySelector('#profile-form');
21096
+ form.addEventListener('submit', (event) => {
21097
+ event.preventDefault();
21098
+ console.log(new FormData(form).get('email'));
21099
+ });
21100
+
21101
+ ```
21102
+
21103
+ The browser runs native validation before dispatching `submit`. Call `form.reportValidity()` to show validation
21104
+ feedback directly, or `form.checkValidity()` for a boolean check without the browser's validation UI.
21105
+
20651
21106
  ## Compose Slots
20652
21107
 
20653
21108
  Default slots hold primary content. Named slots identify component-owned regions.
@@ -20842,16 +21297,16 @@ ripple.dispose();
20842
21297
 
20843
21298
  ## Features
20844
21299
 
20845
- - `createRipple()` creates an isolated graph and lifetime boundary.
21300
+ - `createRipple({ onError })` creates an isolated graph with one runtime failure callback.
20846
21301
  - `signal()` stores writable values with configurable equality.
20847
21302
  - `computed()` derives lazy read-only values.
20848
21303
  - `effect()` reacts to dependency changes with cleanup support.
20849
21304
  - `batch()` coalesces synchronous writes and notifications.
20850
21305
  - `createScope()` groups owned reactive work.
20851
21306
  - `watch()` observes one selected source transition.
20852
- - `tap()` provides runtime observability for all graph events.
20853
21307
  - `resource()` owns focused latest-request async state.
20854
21308
  - `fromSubscribable()` creates a disposable bridge for external `{ getSnapshot, subscribe }` sources.
21309
+ - `RippleError`, `RippleCycleError`, and `RippleDisposedError` distinguish package-originated failures.
20855
21310
 
20856
21311
  ## Documentation
20857
21312
 
@@ -20880,15 +21335,18 @@ ripple.dispose();
20880
21335
  | `createScope()` | Group owned reactive work | Sync | Call `run()` to activate it |
20881
21336
  | `untrack()` | Read without tracking | Sync | Read still happens immediately |
20882
21337
  | `watch()` | Observe selected output | Sync | Use `effect()` for broad reads |
20883
- | `tap()` | Observe runtime events | Sync | Handler errors are swallowed |
21338
+ | `resource()` | Load from tracked reactive input | Sync factory / Async loader | `reload()` returns `void`; completion is observable state |
20884
21339
  | `fromSubscribable()` | Bridge external source | Sync | Returned bridge must be disposed |
20885
21340
  | `isReactive()` | Test Ripple's marked readable contract | Sync | Not a security or provenance check |
21341
+ | `RippleError` | Base package error | Sync | Runtime callback failures retain their original value |
21342
+ | `RippleCycleError` | Computed cycle or runaway flush | Sync | Flush failures reach `onError`, not the write site |
21343
+ | `RippleDisposedError` | Work after disposal | Sync | Cached reads remain available |
20886
21344
 
20887
21345
  ## Package Entry Point
20888
21346
 
20889
21347
  | Import | Purpose |
20890
21348
  | --- | --- |
20891
- | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, tap, and the isolated graph factory |
21349
+ | `@vielzeug/ripple` | All primitives, types, and errors: signals, computed, effects, scopes, watch, and the isolated graph factory |
20892
21350
 
20893
21351
  ## Graph Creation
20894
21352
 
@@ -20898,11 +21356,14 @@ ripple.dispose();
20898
21356
  function createRipple(options?: RippleOptions): Ripple;
20899
21357
  ```
20900
21358
 
20901
- Creates one isolated reactive graph. Factories on the returned object share scheduling, ownership, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `RippleDisposedRuntimeError`. Create a new graph for a new lifetime.
21359
+ Creates one isolated reactive graph. Factories on the returned object share scheduling, ownership, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `RippleDisposedError`. Create a new graph for a new lifetime.
20902
21360
 
20903
21361
  | Parameter | Type | Description |
20904
21362
  | --- | --- | --- |
20905
- | `options.errorPolicy` | `'throw' \| 'swallow'` | Controls whether computed refresh, effect, cleanup, and listener failures rethrow. Default `'throw'`. Error events are always emitted through `tap()`. |
21363
+ | `options.onError` | `(error, context) => void` | Handles computed refresh, effect, cleanup, listener, and flush-loop failures. Default rethrows asynchronously via `queueMicrotask`; pass a no-op to silence. A failure in one node never interrupts sibling propagation. |
21364
+
21365
+ `onError` is the only runtime option. `RippleErrorContext.kind` is `cleanup`, `computed`,
21366
+ `effect`, `flush`, or `listener`; `name` is optional. There is no event stream or error-policy option.
20906
21367
 
20907
21368
  **Returns:** `Ripple`.
20908
21369
 
@@ -20941,7 +21402,7 @@ console.log(isReactive(signal(0)));
20941
21402
 
20942
21403
  ## Default Graph Functions
20943
21404
 
20944
- Root helpers (`signal`, `computed`, `effect`, `batch`, `createScope`, `untrack`, `watch`, `resource`, `fromSubscribable`) operate on a process-lifetime default graph. Use them only when the application has one graph for its entire lifetime: they are never disposed. For isolated, disposable graphs (tests, SSR, embedded features) use `createRipple()`.
21405
+ Root helpers (`signal`, `computed`, `effect`, `batch`, `createScope`, `untrack`, `watch`, `resource`, `fromSubscribable`) operate on a process-lifetime default graph. The graph itself has no public disposal handle; effects, scopes, resources, and bridges still require owner-managed disposal. For isolated, disposable graphs (tests, SSR, embedded features) use `createRipple()`.
20945
21406
 
20946
21407
  ### `signal(initial, options?)`
20947
21408
 
@@ -20973,7 +21434,7 @@ cart.update((state) => ({ ...state, items: state.items + 1 }));
20973
21434
  function computed(derive: () => T, options?: ComputedOptions): Readable;
20974
21435
  ```
20975
21436
 
20976
- Creates a lazy read-only value from reactive reads in `derive`. `peek()` may trigger derivation and throw directly. During dependency propagation, a computed refresh failure is reported through `tap()` with context kind `computed` and does not prevent sibling dependents from updating. A disposed computed returns its last cached value from `peek()` without recomputing.
21437
+ Creates a lazy read-only value from reactive reads in `derive`. `peek()` may trigger derivation and throw directly. During dependency propagation, a computed refresh failure is reported to `onError` with context kind `computed` and does not prevent sibling dependents from updating. A disposed computed returns its last cached value from `peek()` without recomputing; a computed disposed before its first evaluation throws `RippleDisposedError` instead of returning a value.
20977
21438
 
20978
21439
  **Returns:** `Readable`.
20979
21440
 
@@ -20997,6 +21458,12 @@ function effect(callback: () => Cleanup | undefined, options?: EffectOptions): E
20997
21458
 
20998
21459
  Runs immediately and reruns when its tracked reads change. A returned cleanup runs before the next callback or disposal.
20999
21460
 
21461
+ | Parameter | Type | Description |
21462
+ | --- | --- | --- |
21463
+ | `options.name` | `string` | Optional label in error context |
21464
+
21465
+ Effects flush synchronously; `EffectOptions` has no scheduler option. Use `batch()` to coalesce writes.
21466
+
21000
21467
  **Returns:** `EffectHandle`.
21001
21468
 
21002
21469
  **Example:**
@@ -21080,16 +21547,31 @@ function resource(
21080
21547
 
21081
21548
  Creates a disposable readable async state and starts loading immediately. Source changes and `reload()` abort stale work before starting the next loader. The last successful value is preserved as `previous`, including when that value is `undefined`.
21082
21549
 
21550
+ | Parameter | Type | Description |
21551
+ | --- | --- | --- |
21552
+ | `source` | `() => Source` | Tracks reactive input reads |
21553
+ | `loader` | `(source, context) => Promise` | Receives input and a request-owned cancellation signal |
21554
+ | `options.name` | `string` | Optional resource label |
21555
+
21556
+ **Returns:** `Resource`. Source and loader failures become error state, not `onError` reports.
21557
+
21558
+ **Example:**
21559
+
21083
21560
  ```ts
21561
+ import { resource, signal } from '@vielzeug/ripple';
21562
+
21563
+ const userId = signal('42');
21084
21564
  const user = resource(
21085
21565
  () => userId.value,
21086
21566
  (id, { signal }) => fetch(`/users/${id}`, { signal }).then((response) => response.json()),
21087
21567
  );
21568
+ console.log(user.value);
21569
+ user.dispose();
21088
21570
  ```
21089
21571
 
21090
- The state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. Reads remain available after disposal; `reload()` and new subscriptions throw `RippleDisposedResourceError`.
21572
+ The state is `{ status: 'pending', previous? }`, `{ status: 'success', value }`, or `{ status: 'error', error, previous? }`. Reads remain available after disposal; `reload()` and new subscriptions throw `RippleDisposedError`.
21091
21573
 
21092
- ## Watch and Observability
21574
+ ## Watch
21093
21575
 
21094
21576
  ### `watch(source, callback, options?)`
21095
21577
 
@@ -21117,41 +21599,6 @@ stop.dispose();
21117
21599
 
21118
21600
  ---
21119
21601
 
21120
- ### `ripple.tap(handler, options?)`
21121
-
21122
- ```ts
21123
- function tap(
21124
- handler: (event: RippleEvent) => void,
21125
- options?: { signal?: AbortSignal },
21126
- ): Unsubscribe;
21127
- ```
21128
-
21129
- Runtime observability for writes, computes, effects, disposals, and reported errors. Computed refresh errors are reported with context kind `computed` without interrupting sibling propagation. During graph disposal, taps remain active for owned cleanup errors and node disposal events; `dispose:graph` is emitted last. Handler errors are swallowed. The returned function unsubscribes, and an optional signal auto-detaches it.
21130
-
21131
- The `Ripple.tap()` method is available on instances created with `createRipple()`. After runtime disposal it returns a no-op unsubscribe.
21132
-
21133
- **Returns:** `Unsubscribe`.
21134
-
21135
- **Example:**
21136
-
21137
- ```ts
21138
- import { createRipple } from '@vielzeug/ripple';
21139
-
21140
- const ripple = createRipple({ errorPolicy: 'swallow' });
21141
- const stop = ripple.tap((event) => {
21142
- if (event.type === 'error') console.error(event.context.kind, event.error);
21143
- if (event.type === 'write') console.log(`${event.name}: ${event.previous} → ${event.next}`);
21144
- });
21145
-
21146
- const count = ripple.signal(0);
21147
- count.value = 1;
21148
-
21149
- stop();
21150
- ripple.dispose();
21151
- ```
21152
-
21153
- ---
21154
-
21155
21602
  ### `fromSubscribable(source, options?)`
21156
21603
 
21157
21604
  ```ts
@@ -21167,12 +21614,20 @@ Bridges an external `{ getSnapshot, subscribe }` source into a disposable reacti
21167
21614
  ```ts
21168
21615
  import { fromSubscribable } from '@vielzeug/ripple';
21169
21616
 
21170
- const routerState = fromSubscribable({
21171
- getSnapshot: () => router.getSnapshot(),
21172
- subscribe: (cb) => router.subscribe(cb),
21617
+ let current = 'idle';
21618
+ const listeners = new Set void>();
21619
+ const state = fromSubscribable({
21620
+ getSnapshot: () => current,
21621
+ subscribe: (listener) => {
21622
+ listeners.add(listener);
21623
+ return () => { listeners.delete(listener); };
21624
+ },
21173
21625
  });
21174
21626
 
21175
- routerState.dispose();
21627
+ current = 'ready';
21628
+ for (const listener of listeners) listener();
21629
+ console.log(state.value);
21630
+ state.dispose();
21176
21631
  ```
21177
21632
 
21178
21633
  ## Types
@@ -21187,20 +21642,13 @@ type ComputedOptions = { equals?: Equality; name?: string };
21187
21642
  type EffectOptions = { name?: string };
21188
21643
  type WatchOptions = { equals?: Equality; immediate?: boolean; name?: string };
21189
21644
 
21190
- type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'listener'; readonly name?: string };
21191
- type RippleErrorPolicy = 'throw' | 'swallow';
21192
- type RippleOptions = { errorPolicy?: RippleErrorPolicy };
21193
-
21194
- type RippleEvent =
21195
- | { readonly type: 'compute'; readonly name?: string }
21196
- | { readonly type: 'effect'; readonly name?: string }
21197
- | { readonly type: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown }
21198
- | { readonly type: 'dispose'; readonly name?: string; readonly node: 'effect' | 'graph' | 'scope' }
21199
- | { readonly type: 'error'; readonly error: unknown; readonly context: RippleErrorContext };
21645
+ type RippleErrorContext = { readonly kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'; readonly name?: string };
21646
+ type RippleOptions = { onError?: (error: unknown, context: RippleErrorContext) => void };
21200
21647
 
21201
21648
  interface Readable {
21202
21649
  readonly name?: string;
21203
21650
  peek(): T;
21651
+ /** Change-only: the listener is not called on subscribe, only after a tracked source changes. */
21204
21652
  subscribe(listener: () => void): Unsubscribe;
21205
21653
  readonly value: T;
21206
21654
  }
@@ -21241,7 +21689,6 @@ interface Ripple extends Disposable {
21241
21689
  options?: ResourceOptions,
21242
21690
  ): Resource;
21243
21691
  signal(initial: T, options?: SignalOptions): Signal;
21244
- tap(handler: (event: RippleEvent) => void, options?: { signal?: AbortSignal }): Unsubscribe;
21245
21692
  untrack(fn: () => T): T;
21246
21693
  watch(source: Readable | (() => T), callback: (value: T, previous: T | undefined) => void, options?: WatchOptions): EffectHandle;
21247
21694
  }
@@ -21251,12 +21698,13 @@ interface Ripple extends Disposable {
21251
21698
 
21252
21699
  | Error | Trigger | Notable properties |
21253
21700
  | --- | --- | --- |
21254
- | `RippleError` | Base Ripple error | Use `instanceof RippleError` to narrow unknown values. |
21255
- | `RippleComputedCycleError` | Computed dependency reads itself through a cycle | Extends `RippleError`. |
21256
- | `RippleDisposedResourceError` | `reload()` or `subscribe()` used after resource disposal | Extends `RippleError`. |
21257
- | `RippleDisposedRuntimeError` | Factory or execution API used after `ripple.dispose()` | Extends `RippleError`. |
21258
- | `RippleDisposedScopeError` | `scope.run()` after scope disposal | Extends `RippleError`. |
21259
- | `RippleInfiniteLoopError` | Effect flush exceeds graph iteration limit | Extends `RippleError`. |
21701
+ | `RippleError` | Base Ripple error; a new subscription to a disposed external-source bridge | Use `instanceof RippleError` to narrow unknown values. |
21702
+ | `RippleCycleError` | A computed reads itself, or a flush exceeds the iteration limit | Extends `RippleError`. A flush-loop breach is reported to `onError` (context kind `flush`) instead of throwing from the write site. |
21703
+ | `RippleDisposedError` | New graph work, writes, or subscriptions on a disposed runtime; `run()` on a disposed scope; `reload()` or new subscriptions on a disposed resource; reading a never-evaluated disposed computed | Extends `RippleError`. Cached reads and repeated disposal remain valid. |
21704
+
21705
+ These are the three exported error classes. User callback failures are reported as their original
21706
+ values through `onError`; they are not wrapped in `RippleError`. A direct computed read can throw
21707
+ its derivation error to the caller.
21260
21708
 
21261
21709
  ### Usage Guide
21262
21710
 
@@ -21282,10 +21730,8 @@ Use `createRipple()` for tests, SSR requests, embedded applications, or independ
21282
21730
  ```ts
21283
21731
  import { createRipple } from '@vielzeug/ripple';
21284
21732
 
21285
- const ripple = createRipple({ errorPolicy: 'swallow' });
21286
-
21287
- ripple.tap((event) => {
21288
- if (event.type === 'error') console.log(event.context.kind, event.error);
21733
+ const ripple = createRipple({
21734
+ onError: (error, context) => console.log(context.kind, error),
21289
21735
  });
21290
21736
 
21291
21737
  const count = ripple.signal(0);
@@ -21371,26 +21817,28 @@ const stopWatch = ripple.watch(
21371
21817
  stopWatch.dispose();
21372
21818
  ```
21373
21819
 
21374
- ## Observability
21820
+ ## Error Handling
21375
21821
 
21376
- `tap()` receives writes, computes, effects, disposals, and reported errors. Computed refresh failures are isolated and reported without interrupting sibling propagation. During runtime disposal, owned cleanup errors and node disposal events are emitted before the final graph disposal event; handlers remain active until that sequence completes. Tap-handler errors are swallowed.
21822
+ Failures inside effect callbacks, computed derivations, cleanups, and listeners never corrupt the graph: the failing node is reported and siblings keep propagating. The `onError` option decides what reporting means. By default failures rethrow asynchronously (via `queueMicrotask`), surfacing as unhandled errors; pass a handler to route them to a logger or error boundary instead:
21377
21823
 
21378
21824
  ```ts
21379
- const ripple = createRipple({ errorPolicy: 'swallow' });
21380
-
21381
- ripple.tap((event) => {
21382
- switch (event.type) {
21383
- case 'write':
21384
- console.log(`${event.name}: ${event.previous} → ${event.next}`);
21385
- break;
21386
- case 'error':
21387
- console.error(`${event.context.kind}:`, event.error);
21388
- break;
21389
- }
21825
+ const ripple = createRipple({
21826
+ onError: (error, context) => {
21827
+ // context.kind: 'cleanup' | 'computed' | 'effect' | 'flush' | 'listener'
21828
+ console.error(`${context.kind}:`, error);
21829
+ },
21390
21830
  });
21391
21831
  ```
21392
21832
 
21393
- `errorPolicy` controls whether computed refresh, effect, cleanup, and listener failures rethrow (`'throw'`, default) or are silenced (`'swallow'`). Error events are emitted through `tap()` regardless of policy.
21833
+ `onError` is the only runtime option. Effect options accept only a `name`; effects do not have a
21834
+ microtask scheduler. Use `batch()` to coalesce related writes.
21835
+
21836
+ A direct computed read can throw its derivation error to the caller. `onError` handles computed
21837
+ refresh failures during propagation, not every possible synchronous exception. The exported
21838
+ hierarchy is `RippleError`, `RippleCycleError`, and `RippleDisposedError`; callback failures retain
21839
+ their original values.
21840
+
21841
+ A runaway effect (one that keeps re-triggering itself) is caught by the flush iteration limit and reported with context kind `flush`; the flush is abandoned and the graph stays usable for later writes.
21394
21842
 
21395
21843
  ## Bridging External Sources
21396
21844
 
@@ -21426,15 +21874,19 @@ const user = ripple.resource(
21426
21874
 
21427
21875
  const stop = ripple.effect(() => {
21428
21876
  const state = user.value;
21429
- if (state.status === 'success') renderUser(state.value);
21430
- if (state.status === 'error') renderError(state.error);
21877
+ if (state.status === 'success') console.log(state.value);
21878
+ if (state.status === 'error') console.error(state.error);
21431
21879
  });
21432
21880
 
21433
21881
  stop.dispose();
21434
21882
  user.dispose();
21435
21883
  ```
21436
21884
 
21437
- Disposed resources retain readable state but reject `reload()` and new subscriptions with `RippleDisposedResourceError`. Use Sourcerer for paginated collection state and a dedicated server-state cache for shared keyed reads, retries, and invalidation.
21885
+ Source and loader failures become resource error state rather than `onError` reports.
21886
+ `reload()` returns `void`; observe state to know when loading finishes. Disposed resources retain
21887
+ readable state but throw `RippleDisposedError` from `reload()` and new subscriptions. Use Sourcerer
21888
+ for paginated collection state and a dedicated server-state cache for shared keyed reads, retries,
21889
+ and invalidation.
21438
21890
 
21439
21891
  ## Object State
21440
21892
 
@@ -21554,13 +22006,21 @@ ripple.dispose();
21554
22006
 
21555
22007
  ## Gotchas
21556
22008
 
22009
+ ### `subscribe()` is change-only
22010
+
22011
+ `Readable.subscribe(listener)` never calls the listener on subscribe: it fires only after a tracked source changes. Read the current value at the call site when you need an initial state.
22012
+
21557
22013
  ### `subscribe()` forces computed evaluation
21558
22014
 
21559
22015
  `Readable.subscribe()` calls `peek()` before registering the listener. For signals this is a no-op, but for computeds it forces `refresh()`: the derivation runs immediately even if no one reads `.value`. This ensures `equals` comparison works on the first dependency change. Avoid subscribing to expensive computeds unless you need their value.
21560
22016
 
22017
+ ### Diamonds recompute each path once per write
22018
+
22019
+ A computed with dependents refreshes eagerly during propagation, so effects never re-run for unchanged derived values. The tradeoff: in a diamond (`a → b, c → d`), one write to `a` recomputes both `b` and `c` and then `d` once per changed path. Values a consumer observes are always consistent: a dependent reading a stale intermediate pulls the fresh value on read, so no glitched state is ever visible. Prefer keeping diamond depth shallow when a derivation is expensive.
22020
+
21561
22021
  ### Computed first-run failure is recoverable
21562
22022
 
21563
- If a computed's `derive` throws on its first run (e.g., a source is `null`), the computed commits the partial dependencies it tracked before the throw. When a dependency changes and the derivation can succeed, the computed refreshes and notifies its dependents. Effects that read a failing computed report the error through `tap()` and re-run when the computed recovers.
22023
+ If a computed's `derive` throws on its first run (e.g., a source is `null`), the computed commits the partial dependencies it tracked before the throw. When a dependency changes and the derivation can succeed, the computed refreshes and notifies its dependents. Effects that read a failing computed report the error through `onError` and re-run when the computed recovers.
21564
22024
 
21565
22025
  ## Best Practices
21566
22026
 
@@ -21571,8 +22031,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21571
22031
  - Batch related synchronous writes.
21572
22032
  - Use `watch()` only for selected source transitions; callback-only reads are untracked.
21573
22033
  - Dispose external-source bridges when their owner ends.
21574
- - Use `tap()` for runtime observability; set `errorPolicy: 'swallow'` to silence rethrow.
21575
- - Use `resource()` for focused async state and Sourcerer for paginated collection workflows.
22034
+ - Route runtime failures with `onError` and observe async failures through resource state.
21576
22035
 
21577
22036
  ### Examples
21578
22037
 
@@ -21591,7 +22050,7 @@ If a computed's `derive` throws on its first run (e.g., a source is `null`), the
21591
22050
  - Async Resource (id: `async-resource`)
21592
22051
  - Create Graph, Signal, Computed & Effect (id: `basic-signal`)
21593
22052
  - Batch & Untrack (id: `batch-untrack`)
21594
- - Microtask Effect (id: `effect-options`)
22053
+ - Named Effect and onError (id: `effect-options`)
21595
22054
  - Nested Effect Ownership (id: `scope-ownership`)
21596
22055
  - Immutable State (id: `store-basics`)
21597
22056
  - Watch Selected Value (id: `watch-selected-value`)
@@ -28350,7 +28809,9 @@ try {
28350
28809
  - `createPageSource()`: loads numbered pages and exposes direct navigation commands
28351
28810
  - `createCursorSource()`: follows opaque cursors returned by the loader
28352
28811
  - `createInfiniteSource()`: appends pages while preserving loaded items
28812
+ - `createResource()`: starts loading one value immediately, or awaits `reload()` with `start: false`
28353
28813
  - `setParams()`: replaces consumer-owned loader parameters and resets pagination
28814
+ - `PageSource.apply()`: reconciles page and structurally compared params in at most one request
28354
28815
  - `subscribe()`: publishes complete immutable state replacements
28355
28816
 
28356
28817
  ## Documentation
@@ -28377,7 +28838,10 @@ try {
28377
28838
  | `createPageSource()` | Load numbered pages | Async | Construction is inert |
28378
28839
  | `createCursorSource()` | Load server-issued cursor pages | Async | `after` and `before` cannot coexist initially |
28379
28840
  | `createInfiniteSource()` | Append numbered pages | Async | `loadMore()` is a no-op while loading or exhausted |
28841
+ | `createResource()` | Load one reactive value | Async | Starts loading during construction; `start: false` opts out |
28380
28842
  | `SourcererError` | Base package error | Sync | Loader errors retain their original type |
28843
+ | `SourcererConfigurationError` | Invalid pagination configuration | Sync | Pages and page sizes must be positive integers |
28844
+ | `SourcererDisposedError` | Command after source disposal | Sync | In-flight work instead settles without committing |
28381
28845
 
28382
28846
  ## Package Entry Point
28383
28847
 
@@ -28462,6 +28926,7 @@ await source.reload();
28462
28926
 
28463
28927
  | Method | Returns | Description |
28464
28928
  | --- | --- | --- |
28929
+ | `apply(update)` | `Promise` | Reconcile `{ params, page }` in at most one load; params compare structurally |
28465
28930
  | `first()` | `Promise` | Load page one |
28466
28931
  | `goTo(page)` | `Promise` | Load a positive page |
28467
28932
  | `last()` | `Promise` | Load the last known page |
@@ -28473,6 +28938,14 @@ await source.reload();
28473
28938
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28474
28939
  | `dispose()` | `void` | Abort work and stop notifications |
28475
28940
 
28941
+ `apply(update: PageSourceUpdate): Promise` compares params structurally with
28942
+ `isEqual`. Changed params use the supplied positive page without clamping, or page one when omitted.
28943
+ Unchanged params with a page use `goTo()` and clamp after the first successful load.
28944
+ An unchanged request does not refetch a committed result or duplicate an active request; it loads
28945
+ an inert source or retries an uncommitted request after failure. Omitted or `undefined` params
28946
+ preserve the requested value; use `setParams(undefined)` when `undefined` is a valid replacement.
28947
+ The update does not include page size; use `setPageSize()` separately.
28948
+
28476
28949
  ---
28477
28950
 
28478
28951
  ### `createCursorSource()`
@@ -28550,6 +29023,54 @@ await source.loadMore();
28550
29023
  | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
28551
29024
  | `dispose()` | `void` | Abort work and stop notifications |
28552
29025
 
29026
+ ---
29027
+
29028
+ ### `createResource()`
29029
+
29030
+ ```ts
29031
+ function createResource(config: ResourceConfig): Resource
29032
+ ```
29033
+
29034
+ Returns observable state for one asynchronously loaded value. It does not suspend rendering,
29035
+ cache by key, or track external reactive inputs.
29036
+
29037
+ | Parameter | Type | Description |
29038
+ | --- | --- | --- |
29039
+ | `config.load` | `(context: ResourceLoadContext) => Promise` | Loader; receives a signal aborted on disposal and on the next `reload()` |
29040
+ | `config.start` | `boolean` | Start the initial load during construction; defaults to `true` |
29041
+
29042
+ **Returns:** `Resource`.
29043
+
29044
+ ```ts
29045
+ import { createResource } from '@vielzeug/sourcerer';
29046
+
29047
+ const profile = createResource({
29048
+ load: ({ signal }) => fetch('/me', { signal }).then((response) => response.json()),
29049
+ start: false,
29050
+ });
29051
+
29052
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29053
+ console.log(profile.state);
29054
+ try {
29055
+ await profile.reload();
29056
+ } finally {
29057
+ stop();
29058
+ profile.dispose();
29059
+ }
29060
+ ```
29061
+
29062
+ | Method | Returns | Description |
29063
+ | --- | --- | --- |
29064
+ | `reload()` | `Promise` | Refetch with a fresh signal; rejects so callers can await it |
29065
+ | `subscribe(listener)` | `() => void` | Register a state listener and return its unsubscribe function |
29066
+ | `dispose()` | `void` | Abort the in-flight load and stop notifications |
29067
+
29068
+ Construction starts the initial load unless `start: false`. Its failure surfaces through
29069
+ `state.error`; a later `reload()` rejects and retains the previous `data` on failure.
29070
+ A superseded or disposed request settles without touching state. Subscriptions are change-only:
29071
+ read `state` for the current snapshot, including the initial loading state. `dispose()` is idempotent,
29072
+ aborts `disposalSignal`, and leaves the last snapshot readable; subscriptions after disposal are inert.
29073
+
28553
29074
  ## Types
28554
29075
 
28555
29076
  ### Page source types
@@ -28568,8 +29089,11 @@ type PageSourceConfig = Readonly): Promise>;
28568
29089
  } & (undefined extends TParams ? { params?: TParams } : { params: TParams })
28569
29090
  >;
28570
29091
 
29092
+ type PageSourceUpdate = Readonly;
29093
+
28571
29094
  type PageSource = {
28572
29095
  [Symbol.dispose](): void;
29096
+ apply(update: PageSourceUpdate): Promise;
28573
29097
  readonly disposalSignal: AbortSignal;
28574
29098
  dispose(): void;
28575
29099
  readonly disposed: boolean;
@@ -28588,6 +29112,28 @@ type PageSource = {
28588
29112
 
28589
29113
  The conditional config field makes `params` optional only when `TParams` includes `undefined`.
28590
29114
 
29115
+ ### Resource types
29116
+
29117
+ ```ts
29118
+ type ResourceLoadContext = Readonly;
29119
+
29120
+ type ResourceState = Readonly;
29121
+
29122
+ type ResourceConfig = Readonly;
29123
+ start?: boolean;
29124
+ }>;
29125
+
29126
+ type Resource = {
29127
+ [Symbol.dispose](): void;
29128
+ readonly disposalSignal: AbortSignal;
29129
+ dispose(): void;
29130
+ readonly disposed: boolean;
29131
+ reload(): Promise;
29132
+ readonly state: ResourceState;
29133
+ subscribe(listener: (state: ResourceState) => void): () => void;
29134
+ };
29135
+ ```
29136
+
28591
29137
  ### Local source types
28592
29138
 
28593
29139
  ```ts
@@ -28736,7 +29282,7 @@ console.log(source.state.params);
28736
29282
  source.dispose();
28737
29283
  ```
28738
29284
 
28739
- A non-optional parameter type requires an initial `params` value. Sourcerer compares parameter values with `Object.is`; reuse a value when setting it again should be a no-op.
29285
+ A non-optional parameter type requires an initial `params` value. `setParams()` compares values with `Object.is`; `apply()` compares structurally, avoiding redundant requests from a router or reactive effect. Both can load an inert source or retry an uncommitted request. See [Apply Page and Filter Together](#apply-page-and-filter-together).
28740
29286
 
28741
29287
  While replacement parameters load, `state.params` continues to describe the committed items and `state.pendingParams` contains the requested value. Page-only navigation does not set `pendingParams`.
28742
29288
 
@@ -28756,6 +29302,47 @@ await source.setPageSize(50);
28756
29302
 
28757
29303
  After the first successful load, navigation is clamped to the known `pageCount`. Before that load, `goTo()` accepts any positive page because the total is unknown.
28758
29304
 
29305
+ ## Apply Page and Filter Together
29306
+
29307
+ A URL query, a filter form, or a router state usually carries the page and the filters at once. `apply()` accepts both in one call and issues at most one request.
29308
+
29309
+ ```ts
29310
+ import { createPageSource } from '@vielzeug/sourcerer';
29311
+
29312
+ const names = ['Ada', 'Grace', 'Linus', 'Ada Byron'];
29313
+ const source = createPageSource({
29314
+ load: async ({ page, pageSize, params }) => {
29315
+ const matching = names.filter((name) => name.includes(params.search));
29316
+ const start = (page - 1) * pageSize;
29317
+ return { items: matching.slice(start, start + pageSize), totalItems: matching.length };
29318
+ },
29319
+ pageSize: 1,
29320
+ params: { search: '' },
29321
+ });
29322
+
29323
+ try {
29324
+ await source.apply({ page: 2, params: { search: 'Ada' } });
29325
+ console.log(source.state.items); // ['Ada Byron']
29326
+ await source.apply({ params: { search: 'Ada' } });
29327
+ console.log(source.state.pagination.page); // 2; no extra request
29328
+ await source.apply({ params: { search: 'Grace' } });
29329
+ console.log(source.state.pagination.page); // 1
29330
+ } finally {
29331
+ source.dispose();
29332
+ }
29333
+ ```
29334
+
29335
+ Parameters are compared structurally, so a freshly built object with the same contents does not
29336
+ refetch a committed result or duplicate active work. An inert source still loads, and an unchanged
29337
+ request can retry after failure. When parameters change,
29338
+ `page` is validated but never clamped: the page count still describes the previous filter set, so
29339
+ clamping against it would silently move the reader elsewhere. Omit `page` in that case and the
29340
+ source returns to page one. When parameters are unchanged, `apply()` routes through `goTo()` and
29341
+ clamps to the known page count.
29342
+
29343
+ Omitted or `undefined` params keep the requested value. Use `setParams(undefined)` when
29344
+ your parameter type allows `undefined` and you want to replace the value with it.
29345
+
28759
29346
  ## Filter Local Collections
28760
29347
 
28761
29348
  Use a local source when data is already in memory. Its commands are synchronous.
@@ -28831,6 +29418,44 @@ source.dispose();
28831
29418
 
28832
29419
  `loadMore()` is a no-op while another request is active or after `pagination.hasMore` becomes false. `setParams()` and `setPageSize()` replace the feed from page one.
28833
29420
 
29421
+ ## Load a Single Value
29422
+
29423
+ Use a resource for one reactive value: a profile, a settings document, a pricing summary. It loads once and refetches on demand.
29424
+
29425
+ ```ts
29426
+ import { createResource } from '@vielzeug/sourcerer';
29427
+
29428
+ type Profile = { name: string };
29429
+ const profile = createResource({
29430
+ load: async ({ signal }) => {
29431
+ const response = await fetch('/me', { signal });
29432
+ if (!response.ok) throw new Error(`Profile request failed: ${response.status}`);
29433
+ return response.json();
29434
+ },
29435
+ start: false,
29436
+ });
29437
+
29438
+ const stop = profile.subscribe((state) => console.log(state.data, state.loading, state.error));
29439
+ console.log(profile.state);
29440
+ try {
29441
+ await profile.reload();
29442
+ } catch (error) {
29443
+ console.error(error);
29444
+ } finally {
29445
+ stop();
29446
+ profile.dispose();
29447
+ }
29448
+ ```
29449
+
29450
+ A resource starts loading during construction by default; the example uses `start: false` to await
29451
+ the first load explicitly. Automatic initial-load failures land in `state.error` without an
29452
+ unhandled rejection. Explicit `reload()` calls reject current failures and preserve previous `data`.
29453
+ Reloading aborts older work, whose promise settles without committing.
29454
+
29455
+ Read `state` before subscribing when you need the initial snapshot: subscriptions notify only
29456
+ changes. A resource does not track Ripple signals or cache values by key; call `reload()` when
29457
+ external inputs change. Disposal aborts work and retains the last readable snapshot.
29458
+
28834
29459
  ## Handle Requests and Errors
28835
29460
 
28836
29461
  A new remote command aborts older work. Superseded or disposed work settles without committing. A current loader failure preserves committed items, writes the error to state, and rejects the command.
@@ -28963,6 +29588,7 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28963
29588
  - [Page Params with URL State](./examples/remote-search-with-url-state.md)
28964
29589
  - [Cursor-Based Pagination](./examples/cursor-based-pagination.md)
28965
29590
  - [Infinite Scroll](./examples/infinite-scroll.md)
29591
+ - [Load a Single Value](./examples/single-value-resource.md)
28966
29592
  - [Framework Integration](./examples/framework-integration.md)
28967
29593
  - [Remote Data with Courier](./examples/sourcerer-with-courier.md)
28968
29594
  - [Reactive Controls with Ripple](./examples/sourcerer-with-ripple.md)
@@ -28970,12 +29596,14 @@ Use Scout to compute indexed local matches, Ripple to project state into reactiv
28970
29596
 
28971
29597
  ### REPL Examples
28972
29598
 
29599
+ - apply - Page and params in one call (id: `apply`)
28973
29600
  - Cursor Source (id: `cursor-source`)
28974
29601
  - Error Handling (id: `error-handling`)
28975
29602
  - Infinite Source (id: `infinite-source`)
28976
29603
  - Source Lifecycle (id: `lifecycle`)
28977
29604
  - Local Source (id: `local-source`)
28978
29605
  - Page Source (id: `page-source`)
29606
+ - createResource - One reactive value (id: `resource`)
28979
29607
 
28980
29608
  ---
28981
29609