effectweb 0.3.0 → 0.4.0

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.
Files changed (59) hide show
  1. package/README.md +35 -4
  2. package/dist/AsyncContent.d.ts +1 -1
  3. package/dist/AsyncContent.js +18 -35
  4. package/dist/boundary.d.ts +11 -0
  5. package/dist/boundary.js +66 -0
  6. package/dist/cache-internals.d.ts +4 -0
  7. package/dist/cache.d.ts +21 -5
  8. package/dist/cache.js +178 -25
  9. package/dist/collection.d.ts +1 -1
  10. package/dist/collection.js +1 -3
  11. package/dist/component.d.ts +3 -0
  12. package/dist/component.js +73 -10
  13. package/dist/dom.d.ts +35 -24
  14. package/dist/dom.js +177 -128
  15. package/dist/effectEvent.d.ts +5 -4
  16. package/dist/effectEvent.js +29 -38
  17. package/dist/form.d.ts +2 -1
  18. package/dist/http.d.ts +2 -1
  19. package/dist/http.js +1 -1
  20. package/dist/index.d.ts +7 -4
  21. package/dist/index.js +6 -3
  22. package/dist/infinite-query.d.ts +42 -0
  23. package/dist/infinite-query.js +150 -0
  24. package/dist/jsx-runtime.d.ts +6 -0
  25. package/dist/jsx-runtime.js +14 -1
  26. package/dist/jsx.d.ts +2 -1
  27. package/dist/keyed-tasks.d.ts +30 -0
  28. package/dist/keyed-tasks.js +102 -0
  29. package/dist/lazy.d.ts +2 -1
  30. package/dist/lazy.js +20 -7
  31. package/dist/load.d.ts +1 -1
  32. package/dist/load.js +1 -1
  33. package/dist/mount.d.ts +13 -2
  34. package/dist/mount.js +82 -21
  35. package/dist/owner.d.ts +9 -5
  36. package/dist/owner.js +27 -9
  37. package/dist/pages.d.ts +16 -15
  38. package/dist/pages.js +8 -5
  39. package/dist/program.d.ts +14 -2
  40. package/dist/program.js +96 -36
  41. package/dist/query-internals.d.ts +5 -3
  42. package/dist/query.d.ts +7 -1
  43. package/dist/query.js +7 -3
  44. package/dist/resource.js +3 -1
  45. package/dist/runtime.d.ts +2 -1
  46. package/dist/runtime.js +3 -1
  47. package/dist/session.js +23 -7
  48. package/dist/settlement.d.ts +8 -0
  49. package/dist/settlement.js +32 -0
  50. package/dist/sharing.js +16 -13
  51. package/dist/snapshot.d.ts +1 -1
  52. package/dist/snapshot.js +31 -25
  53. package/dist/task.d.ts +1 -1
  54. package/dist/task.js +3 -1
  55. package/dist/tasks.d.ts +1 -1
  56. package/dist/tasks.js +3 -1
  57. package/dist/testing.d.ts +5 -4
  58. package/dist/testing.js +4 -2
  59. package/package.json +9 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # effectweb
2
2
 
3
- Immutable Effect models and JSX compiled to direct DOM updates, without signals, proxies, or virtual DOM.
3
+ Immutable Effect models and JSX with direct DOM rendering.
4
4
 
5
5
  Use with `@effectweb/compiler/vite` and Effect `4.0.0-rc.112`. The package includes programs, named tasks, async presentation, query caching, DOM lifetimes, and `effectweb/testing` helpers.
6
6
 
@@ -8,6 +8,37 @@ See [setup and example](https://github.com/DerpyCrabs/EffectWeb#vite-setup).
8
8
 
9
9
  Runtime and compiler versions advance together. Client-side only. Persistence and multi-tab coordination belong to the application.
10
10
 
11
+ ## Views and list identity
12
+
13
+ `view(render)` evaluates `render(model, send)` as ordinary JavaScript on each immutable model publication. Calls, local variables, destructuring, loops, conditionals, and JSX helpers retain their JavaScript behavior. The renderer reconciles the returned content; the compiler does not infer dependencies or cache helper results.
14
+
15
+ Use `list(rows, render)` to preserve domain identity:
16
+
17
+ ```tsx
18
+ import { entities, list, view } from 'effectweb';
19
+
20
+ type Todo = { id: string; title: string };
21
+ const Todos = view<{ todos: Todo[] }>((model) => (
22
+ <ul>
23
+ {list(entities(model.todos), (todo) => (
24
+ <li>{todo.title}</li>
25
+ ))}
26
+ </ul>
27
+ ));
28
+ ```
29
+
30
+ `collection(identity).from(items)` supplies a custom domain identity; `sequence(items)` supplies positional identity. `list(rawArray, render)` uses each item itself as its identity, so repeated values or references require an explicit collection identity. Duplicate identities throw with both positions. The callback receives the exact current item, including a replacement object with the same ID. `Rows.map` and ordinary array `.map` remain normal JavaScript mapping operations; their rendered arrays use positional identity.
31
+
32
+ `slot(render)` is a typed render callback. Call it explicitly, including `footer()` for a `Slot<void>`; a function is not implicit JSX content. See the [0.4.0 migration guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/migration-0.4.0.md).
33
+
34
+ ## DOM and Effect lifetimes
35
+
36
+ `domMount(acquire)` uses the acquisition function as its identity. Declare it outside the view body when its lifetime should survive model updates. `domBinding(data, acquire)` keeps a stable acquisition while supplying fresh data through `input()`. Return `{ update, dispose }` when the integration must apply changed data, or a scoped Effect that remains active for the lifetime. A new acquisition function replaces the old lifetime.
37
+
38
+ Fresh event callbacks see current model data without canceling already running listener work. An Effect event's `replace` policy cancels the previous request when a new event submits work; removing the handler or unmounting disposes its owned work.
39
+
40
+ `dispose()` interrupts owned work synchronously. `close()`, `awaitIdle()`, and `awaitStopped()` return Effects; merely creating or JavaScript-awaiting one does not execute it. Use `yield* owner.close()` inside an Effect or `await Effect.runPromise(owner.close())` at a Promise integration boundary. `close()` waits for finalizers; `awaitIdle()` waits without closing the owner. A mount returned by `mountView` also has `close()` for its DOM-owned work. The supplied program keeps its separate ownership.
41
+
11
42
  ## Query identity and account ownership
12
43
 
13
44
  A query definition has its own identity. Within one cache, all request arguments form its key automatically:
@@ -76,7 +107,7 @@ Queue progress continues after successes, typed failures, or defects. Component
76
107
 
77
108
  `<Portal mount={model.dialogHost}>...</Portal>` renders into a supplied HTML or SVG element. Omitting `mount` uses the document body. Portal content retains its owner, events, and cleanup; changing between HTML and SVG targets rebuilds content in the correct namespace.
78
109
 
79
- ## Inspect source dependencies
110
+ ## Inspect source bindings
80
111
 
81
112
  Mount a development panel before mounting the application so it sees initial evaluations:
82
113
 
@@ -88,11 +119,11 @@ const removeInspector = mountBindingInspector(document.querySelector<HTMLElement
88
119
  // removeInspector();
89
120
  ```
90
121
 
91
- The live, filterable table shows original file/line/column, source expressions, inferred snapshot dependencies, the latest changed dependency names, and derive/binding evaluation counts. It uses development compiler metadata; production builds emit none. Counts include initial evaluations, aggregate instances of the same source expression, and measure evaluations rather than actual DOM writes. Change reasons use reference/value equality, without retaining previous or next values.
122
+ The live, filterable table shows original file/line/column, source expressions, and binding evaluation counts. Development metadata records the expressions actually evaluated for text and attributes; it does not infer which model fields a helper reads. Production builds emit none. Counts include initial evaluations, aggregate instances of the same source expression, and measure binding evaluations rather than DOM writes. Change reasons compare evaluated values without retaining previous or next values.
92
123
 
93
124
  For custom tooling, `inspectBindings({ limit: 200 })` returns `entries()`, `subscribe(listener)`, `clear()`, and `dispose()`. Entries are immutable metadata, newest first, with at most 1000 source records. Least recently updated sources are evicted and start fresh if seen again. `dispose()` unsubscribes and clears retained metadata. `mountBindingInspector(element, inspector)` can share an inspector; removing that panel leaves the supplied inspector running. Low-level `observeBindings` remains available.
94
125
 
95
- The inspector covers instrumented derivations and text/attribute bindings; it is not a snapshot recorder, time-travel debugger, or complete profile of branch/list reconciliation. Source labels describe the compiler's inferred dependencies, not a proof that an opaque helper has no hidden state.
126
+ The inspector covers instrumented text/attribute bindings; it is not a snapshot recorder, time-travel debugger, or complete profile of view evaluation and list reconciliation. Use application profiling for expensive render helpers.
96
127
 
97
128
  ## Immutable inputs and outputs
98
129
 
@@ -1,4 +1,4 @@
1
- import { Cause } from 'effect';
1
+ import * as Cause from 'effect/Cause';
2
2
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
3
  import { type Slot, type View } from './index.js';
4
4
  import type { JSX } from './jsx.js';
@@ -1,6 +1,8 @@
1
1
  import * as _ew_dom from "./dom.js";
2
2
  import { commandSlot } from "./program.js";
3
- import { Cause, Effect, Option } from "effect";
3
+ import * as Cause from "effect/Cause";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Option from "effect/Option";
4
6
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5
7
  import { component } from "./component.js";
6
8
  import { view } from "./index.js";
@@ -47,40 +49,21 @@ const implementation = component({
47
49
  ...model,
48
50
  visible: true
49
51
  } }),
50
- view: /* @__PURE__ */ _ew_dom.compiled((_ew_scope_1, _ew_parent_2, _ew_before_3) => {
51
- const _ew_derived_4 = _ew_scope_1.derive(() => [_ew_scope_1.value?.props], () => {
52
- const _ew_capture_6 = _ew_scope_1.value;
53
- return _ew_capture_6.props;
54
- });
55
- const _ew_derived_7 = _ew_scope_1.derive(() => [_ew_derived_4()?.result], () => {
56
- const _ew_capture_9 = _ew_derived_4();
57
- return AsyncResult.value(_ew_capture_9.result);
58
- });
59
- const _ew_derived_10 = _ew_scope_1.derive(() => [_ew_derived_4()?.result, _ew_derived_4()?.result?.waiting], () => {
60
- const _ew_capture_12 = _ew_derived_4();
61
- return AsyncResult.isFailure(_ew_capture_12.result) && !_ew_capture_12.result.waiting ? _ew_capture_12.result : undefined;
62
- });
63
- _ew_dom.branch(_ew_scope_1, _ew_parent_2, _ew_before_3, () => Option.isSome(_ew_derived_7()), (_ew_scope_1, _ew_parent_13, _ew_before_14) => {
64
- _ew_dom.text(_ew_scope_1, _ew_parent_13, _ew_before_14, () => [_ew_derived_4(), _ew_derived_7()?.value], () => _ew_derived_4().content(_ew_derived_7().value));
65
- _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_4().result.waiting, (_ew_scope_1, _ew_parent_15, _ew_before_16) => {
66
- _ew_dom.text(_ew_scope_1, _ew_parent_15, _ew_before_16, () => [_ew_derived_4()?.refreshing], () => _ew_derived_4().refreshing);
67
- }, (_ew_scope_1, _ew_parent_15, _ew_before_16) => { });
68
- _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_17, _ew_before_18) => {
69
- _ew_dom.text(_ew_scope_1, _ew_parent_17, _ew_before_18, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
70
- }, (_ew_scope_1, _ew_parent_17, _ew_before_18) => { });
71
- }, (_ew_scope_1, _ew_parent_13, _ew_before_14) => {
72
- _ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
73
- _ew_dom.text(_ew_scope_1, _ew_parent_19, _ew_before_20, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
74
- }, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
75
- _ew_dom.branch(_ew_scope_1, _ew_parent_19, _ew_before_20, () => _ew_scope_1.value.pending, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
76
- _ew_dom.branch(_ew_scope_1, _ew_parent_21, _ew_before_22, () => _ew_scope_1.value.visible, (_ew_scope_1, _ew_parent_23, _ew_before_24) => {
77
- _ew_dom.text(_ew_scope_1, _ew_parent_23, _ew_before_24, () => [_ew_derived_4()?.pending], () => _ew_derived_4().pending);
78
- }, (_ew_scope_1, _ew_parent_23, _ew_before_24) => { });
79
- }, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
80
- _ew_dom.text(_ew_scope_1, _ew_parent_21, _ew_before_22, () => [_ew_derived_4()?.empty], () => _ew_derived_4().empty);
81
- });
82
- });
83
- });
52
+ view: view((model, _send) => {
53
+ const props = model.props;
54
+ const data = AsyncResult.value(props.result);
55
+ const failure = AsyncResult.isFailure(props.result) && !props.result.waiting ? props.result : undefined;
56
+ if (Option.isSome(data))
57
+ return [
58
+ props.content(data.value),
59
+ props.result.waiting ? props.refreshing : null,
60
+ failure && props.failure ? props.failure(failure.cause) : null
61
+ ];
62
+ if (failure && props.failure)
63
+ return props.failure(failure.cause);
64
+ if (model.pending)
65
+ return model.visible ? props.pending : null;
66
+ return props.empty;
84
67
  })
85
68
  });
86
69
  /** Presentation only: resource owners choose identity, loading, caching and cancellation. */
@@ -0,0 +1,11 @@
1
+ import { type View } from './dom.js';
2
+ import { type ReportError } from './errors.js';
3
+ import { type Snapshot } from './snapshot.js';
4
+ export declare function errorBoundary<Model, Message>(content: View<Model, Message>, options: {
5
+ readonly fallback: View<{
6
+ readonly model: NoInfer<Model>;
7
+ readonly error: unknown;
8
+ }, NoInfer<Message>>;
9
+ readonly reset?: (model: Snapshot<NoInfer<Model>>) => unknown;
10
+ readonly onError?: ReportError;
11
+ }): View<Model, Message>;
@@ -0,0 +1,66 @@
1
+ import { child, compiled, viewRegion } from './dom.js';
2
+ import { reportError, reportSafely } from './errors.js';
3
+ import { protectSnapshot } from './snapshot.js';
4
+ export function errorBoundary(content, options) {
5
+ return compiled((scope, parent, before) => {
6
+ let failed = false;
7
+ let error;
8
+ let generation = 0;
9
+ let reset = options.reset?.(scope.value);
10
+ const fallback = compiled((scope, parent, before) => {
11
+ child(scope, parent, before, options.fallback, () => [scope.value], () => protectSnapshot({ model: scope.value, error }), scope.send);
12
+ });
13
+ const region = { manual: true, report: scope.report };
14
+ const render = viewRegion(scope, parent, before, region);
15
+ function showFallback() {
16
+ if (scope.disposed || !failed)
17
+ return;
18
+ region.report = scope.report;
19
+ try {
20
+ render(fallback);
21
+ }
22
+ catch (error) {
23
+ reportSafely(scope.report, error);
24
+ }
25
+ }
26
+ function fail(cause, attempt) {
27
+ if (scope.disposed || attempt !== generation) {
28
+ reportSafely(options.onError ?? reportError, cause);
29
+ return;
30
+ }
31
+ failed = true;
32
+ error = cause;
33
+ const token = ++generation;
34
+ render(undefined);
35
+ reportSafely(options.onError ?? reportError, cause);
36
+ queueMicrotask(() => {
37
+ if (token === generation)
38
+ showFallback();
39
+ });
40
+ }
41
+ const update = () => {
42
+ const next = options.reset?.(scope.value);
43
+ if (!Object.is(reset, next)) {
44
+ reset = next;
45
+ generation++;
46
+ render(undefined);
47
+ failed = false;
48
+ error = undefined;
49
+ }
50
+ if (failed)
51
+ showFallback();
52
+ else {
53
+ const attempt = generation;
54
+ region.report = (cause) => fail(cause, attempt);
55
+ try {
56
+ render(content);
57
+ }
58
+ catch (error) {
59
+ fail(error, attempt);
60
+ }
61
+ }
62
+ };
63
+ scope.jobs.push(update);
64
+ update();
65
+ });
66
+ }
@@ -5,6 +5,10 @@ import type { QueryCache } from './cache.js';
5
5
  import type { Query } from './query.js';
6
6
  import type { Snapshot } from './snapshot.js';
7
7
  export interface CacheInternals<R> {
8
+ refresh(atom: Atom.Atom<unknown>): void;
9
+ readonly disposed: () => boolean;
10
+ revision<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): number | undefined;
11
+ retain(atom: Atom.Atom<unknown>): () => void;
8
12
  readonly registry: AtomRegistry.AtomRegistry;
9
13
  readonly generation: Atom.Writable<number>;
10
14
  query<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
package/dist/cache.d.ts CHANGED
@@ -1,14 +1,28 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
- import { Effect } from 'effect';
3
- import { type Query } from './query.js';
2
+ import * as Effect from 'effect/Effect';
3
+ import { type Query, type QueryGroup } from './query.js';
4
4
  import type { UiRuntime } from './runtime.js';
5
5
  export { loadEffect, type UiLoad } from './load.js';
6
6
  export { shareValue } from './share.js';
7
- export declare function makeQueryCache(): QueryCache<never>;
8
- export declare function makeQueryCache<R>(runtime: UiRuntime<R>): QueryCache<R>;
7
+ export interface QueryCacheOptions {
8
+ readonly retention?: number;
9
+ readonly unused?: 'retain' | 'cancel' | undefined;
10
+ }
11
+ export declare function makeQueryCache(options?: QueryCacheOptions): QueryCache<never>;
12
+ export declare function makeQueryCache<R>(runtime: UiRuntime<R>, options?: QueryCacheOptions): QueryCache<R>;
9
13
  /** A cache owns one registry and the query resources published through it. */
10
14
  export interface QueryCache<R = never> {
11
- prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Effect.Effect<Snapshot<A>, E>;
15
+ batch(work: () => void): void;
16
+ getQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Snapshot<A> | undefined;
17
+ invalidateWhere<Args, A, E>(definition: Query<Args, A, E, R>, predicate: (args: Snapshot<Args>) => boolean): void;
18
+ invalidateGroup(group: QueryGroup): void;
19
+ /** Cancel requests while retaining the last success. Explicit refresh restarts them. */
20
+ cancelQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
21
+ /** Clear cached data and cancel requests; a subsequent selection or refresh reloads. */
22
+ removeQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
23
+ prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, options?: {
24
+ readonly refresh?: boolean;
25
+ }): Effect.Effect<Snapshot<A>, E>;
12
26
  /** Publish a protected success, including undefined, and supersede any pending load for this key. */
13
27
  setQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, value: NoInfer<A> | Snapshot<NoInfer<A>>): Snapshot<A>;
14
28
  /**
@@ -21,5 +35,7 @@ export interface QueryCache<R = never> {
21
35
  /** Observe account resets without exposing registry mutation. */
22
36
  onReset(listener: () => void): () => void;
23
37
  resetResources(): void;
38
+ /** Interrupt every request and wait for its finalizers, including previously canceled requests. */
39
+ close(): Effect.Effect<void>;
24
40
  dispose(): void;
25
41
  }
package/dist/cache.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { Settlement } from './settlement.js';
1
2
  import { protectSnapshot } from './snapshot.js';
2
- import { Effect, Option } from 'effect';
3
+ import * as Deferred from 'effect/Deferred';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Option from 'effect/Option';
3
6
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
4
7
  import * as Atom from 'effect/unstable/reactivity/Atom';
5
8
  import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
@@ -10,18 +13,29 @@ import { registerCache } from './cache-internals.js';
10
13
  import { queryDefinition } from './query-internals.js';
11
14
  export { loadEffect } from './load.js';
12
15
  export { shareValue } from './share.js';
13
- export function makeQueryCache(runtime) {
14
- return createQueryCache(runtime);
16
+ export function makeQueryCache(runtimeOrOptions, options = {}) {
17
+ return runtimeOrOptions && 'provide' in runtimeOrOptions
18
+ ? createQueryCache(runtimeOrOptions, options)
19
+ : createQueryCache(undefined, runtimeOrOptions);
15
20
  }
16
- function createQueryCache(runtime) {
17
- const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
21
+ function createQueryCache(runtime, options = {}) {
22
+ const retention = options.retention ?? 30_000;
23
+ if (!Number.isFinite(retention) || retention < 0)
24
+ throw new RangeError('Query retention must be finite and nonnegative.');
25
+ const registry = AtomRegistry.make({ defaultIdleTTL: retention });
26
+ const cancelValue = Symbol('cancel');
27
+ const removeValue = Symbol('remove');
18
28
  const generation = Atom.keepAlive(Atom.make(0));
19
29
  const resources = new Map();
30
+ const entriesByAtom = new WeakMap();
20
31
  // Follow registry-node lifetime; retaining definitions must not retain evicted data.
21
32
  const values = new WeakMap();
22
33
  const identities = new WeakMap();
23
34
  let disposed = false;
35
+ const settlement = new Settlement();
36
+ let closing;
24
37
  let nextId = 0;
38
+ let nextRevision = 0;
25
39
  const identity = (definition) => {
26
40
  let id = identities.get(definition);
27
41
  if (id === undefined) {
@@ -37,28 +51,54 @@ function createQueryCache(runtime) {
37
51
  else {
38
52
  const loaded = Atom.make((get) => {
39
53
  const previous = Option.flatMap(get.self(), AsyncResult.value);
40
- return loadEffect(() => next.load()).pipe(Effect.map((value) => {
41
- const shared = Option.isSome(previous)
42
- ? share(previous.value, value)
43
- : value;
44
- const snapshot = protectSnapshot(shared);
45
- remember(next, snapshot);
46
- return snapshot;
47
- }));
54
+ next.revision = ++nextRevision;
55
+ next.canceled = false;
56
+ const revision = next.revision;
57
+ return Effect.suspend(() => {
58
+ const finish = settlement.begin();
59
+ return loadEffect(() => next.load()).pipe(Effect.map((value) => {
60
+ const shared = Option.isSome(previous)
61
+ ? share(previous.value, value)
62
+ : value;
63
+ const snapshot = protectSnapshot(shared);
64
+ if (!disposed && revision === next.revision)
65
+ remember(next, snapshot);
66
+ return snapshot;
67
+ }), Effect.ensuring(Effect.sync(finish)));
68
+ });
48
69
  });
49
70
  const next = {
50
71
  load,
72
+ users: 0,
73
+ prefetches: new Set(),
74
+ revision: 0,
51
75
  atom: Atom.writable(loaded.read, (context, value) => {
52
76
  Atom.batch(() => {
77
+ next.revision = ++nextRevision;
53
78
  // Refresh disposes the load lifetime; setSelf publishes without starting another load.
54
79
  context.refreshSelf();
55
- remember(next, value);
56
- context.setSelf(AsyncResult.success(value));
80
+ if (value === cancelValue || value === removeValue) {
81
+ const previous = value === removeValue ? undefined : previousValue(next);
82
+ next.canceled = true;
83
+ if (value === removeValue) {
84
+ delete next.loadedAt;
85
+ const node = registry.getNodes().get(next.atom);
86
+ if (node)
87
+ values.delete(node);
88
+ }
89
+ context.setSelf(previous ? AsyncResult.success(previous.value) : AsyncResult.initial());
90
+ }
91
+ else {
92
+ next.canceled = false;
93
+ remember(next, value);
94
+ context.setSelf(AsyncResult.success(value));
95
+ }
57
96
  });
58
97
  }),
59
98
  };
60
99
  entry = next;
61
100
  resources.set(key, entry);
101
+ entriesByAtom.set(entry.atom, entry);
62
102
  }
63
103
  // Bound definitions as well as the registry's values. Mounted resources stay shared.
64
104
  if (resources.size > 512) {
@@ -90,26 +130,117 @@ function createQueryCache(runtime) {
90
130
  const config = queryDefinition(definition);
91
131
  protectSnapshot(args);
92
132
  const { entry, atom } = acquire(queryKey(definition, args), () => {
93
- const effect = Effect.suspend(() => config.load(args));
133
+ const effect = Effect.suspend(() => config.load(args, previousValue(resources.get(queryKey(definition, args)))?.value));
94
134
  return runtime ? runtime.provide(effect) : effect;
95
135
  }, config.share);
96
136
  entry.query = definition;
137
+ entry.args = args;
138
+ entry.groups = config.groups;
139
+ entry.unused = config.unused ?? options.unused;
97
140
  return { entry, atom, config };
98
141
  };
142
+ const refresh = (entry) => {
143
+ entry.revision = ++nextRevision;
144
+ registry.refresh(entry.atom);
145
+ };
99
146
  const selectQuery = (definition, args) => {
100
147
  const { entry, atom, config } = acquireQuery(definition, args);
148
+ if (entry.canceled && !previousValue(entry))
149
+ refresh(entry);
101
150
  if (registry.getNodes().has(atom) &&
102
151
  entry.loadedAt !== undefined &&
103
152
  Date.now() - entry.loadedAt >= config.staleTime) {
104
153
  const current = registry.get(atom);
105
154
  if (!current.waiting)
106
- registry.refresh(atom);
155
+ refresh(entry);
107
156
  }
108
157
  return atom;
109
158
  };
159
+ const retain = (atom) => {
160
+ const entry = entriesByAtom.get(atom);
161
+ if (!entry)
162
+ return () => { };
163
+ entry.users++;
164
+ let released = false;
165
+ return () => {
166
+ if (released)
167
+ return;
168
+ released = true;
169
+ entry.users--;
170
+ if (!disposed &&
171
+ entry.users === 0 &&
172
+ entry.unused === 'cancel' &&
173
+ registry.getNodes().has(entry.atom) &&
174
+ registry.get(entry.atom).waiting)
175
+ registry.set(entry.atom, cancelValue);
176
+ };
177
+ };
178
+ const abortPrefetches = (entry) => {
179
+ // oxlint-disable-next-line unicorn/no-useless-spread -- Cancellation can add or remove observers reentrantly.
180
+ for (const abort of [...entry.prefetches])
181
+ abort();
182
+ };
110
183
  const cache = {
111
- prefetch(definition, args) {
112
- return Effect.suspend(() => AtomRegistry.getResult(registry, selectQuery(definition, args), { suspendOnWaiting: true }));
184
+ batch: Atom.batch,
185
+ getQueryData(definition, args) {
186
+ return previousValue(resources.get(queryKey(definition, args)))?.value;
187
+ },
188
+ invalidateWhere(definition, predicate) {
189
+ const selected = [...resources.values()].filter((entry) => entry.query === definition && predicate(entry.args));
190
+ Atom.batch(() => {
191
+ for (const entry of selected)
192
+ refresh(entry);
193
+ });
194
+ },
195
+ invalidateGroup(group) {
196
+ Atom.batch(() => {
197
+ for (const entry of resources.values())
198
+ if (entry.groups?.includes(group))
199
+ refresh(entry);
200
+ });
201
+ },
202
+ cancelQuery(definition, ...selected) {
203
+ Atom.batch(() => {
204
+ for (const [key, entry] of resources)
205
+ if (entry.query === definition &&
206
+ (!selected.length || key === queryKey(definition, selected[0])))
207
+ if (registry.getNodes().has(entry.atom) && registry.get(entry.atom).waiting) {
208
+ abortPrefetches(entry);
209
+ registry.set(entry.atom, cancelValue);
210
+ }
211
+ });
212
+ },
213
+ removeQuery(definition, ...selected) {
214
+ Atom.batch(() => {
215
+ for (const [key, entry] of resources)
216
+ if (entry.query === definition &&
217
+ (!selected.length || key === queryKey(definition, selected[0])))
218
+ if (registry.getNodes().has(entry.atom)) {
219
+ abortPrefetches(entry);
220
+ registry.set(entry.atom, removeValue);
221
+ }
222
+ });
223
+ },
224
+ prefetch(definition, args, options) {
225
+ return Effect.suspend(() => {
226
+ checkWritable();
227
+ const atom = selectQuery(definition, args);
228
+ const release = retain(atom);
229
+ const entry = entriesByAtom.get(atom);
230
+ const signal = Deferred.makeUnsafe();
231
+ const abort = () => {
232
+ Deferred.doneUnsafe(signal, Effect.interrupt);
233
+ };
234
+ entry.prefetches.add(abort);
235
+ return Effect.suspend(() => {
236
+ if (options?.refresh && registry.getNodes().has(atom) && !registry.get(atom).waiting)
237
+ refresh(entry);
238
+ return Effect.raceFirst(Deferred.await(signal), AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }));
239
+ }).pipe(Effect.ensuring(Effect.sync(() => {
240
+ entry.prefetches.delete(abort);
241
+ release();
242
+ })));
243
+ });
113
244
  },
114
245
  setQueryData(definition, args, value) {
115
246
  checkWritable();
@@ -140,12 +271,10 @@ function createQueryCache(runtime) {
140
271
  if (selected.length) {
141
272
  const entry = resources.get(queryKey(definition, selected[0]));
142
273
  if (entry)
143
- registry.refresh(entry.atom);
274
+ refresh(entry);
144
275
  }
145
276
  else {
146
- for (const entry of resources.values())
147
- if (entry.query === definition)
148
- registry.refresh(entry.atom);
277
+ cache.invalidateWhere(definition, () => true);
149
278
  }
150
279
  },
151
280
  onReset(listener) {
@@ -155,21 +284,45 @@ function createQueryCache(runtime) {
155
284
  resetResources() {
156
285
  Atom.batch(() => {
157
286
  for (const entry of resources.values()) {
287
+ abortPrefetches(entry);
158
288
  entry.load = () => Effect.interrupt;
159
- registry.refresh(entry.atom);
289
+ refresh(entry);
160
290
  }
161
291
  resources.clear();
162
292
  registry.update(generation, (value) => value + 1);
163
293
  });
164
294
  },
295
+ close() {
296
+ if (!closing) {
297
+ closing = Effect.suspend(() => {
298
+ cache.dispose();
299
+ return settlement.wait();
300
+ });
301
+ }
302
+ return closing;
303
+ },
165
304
  dispose() {
166
305
  if (disposed)
167
306
  return;
168
307
  disposed = true;
308
+ for (const entry of resources.values())
309
+ abortPrefetches(entry);
169
310
  registry.dispose();
170
311
  resources.clear();
171
312
  },
172
313
  };
173
- registerCache(cache, { registry, generation, query: selectQuery });
314
+ registerCache(cache, {
315
+ disposed: () => disposed,
316
+ refresh: (atom) => {
317
+ const entry = entriesByAtom.get(atom);
318
+ if (entry)
319
+ refresh(entry);
320
+ },
321
+ registry,
322
+ generation,
323
+ query: selectQuery,
324
+ retain,
325
+ revision: (definition, args) => resources.get(queryKey(definition, args))?.revision,
326
+ });
174
327
  return Object.freeze(cache);
175
328
  }
@@ -9,7 +9,7 @@ export interface Rows<A> {
9
9
  slice(start?: number, end?: number): Rows<A>;
10
10
  }
11
11
  /** Validate without retaining values. Positions are zero-based array indices. */
12
- export declare function validateIdentities<A>(items: readonly A[], identity: (item: A, index: number) => Identity): Identity[];
12
+ export declare function validateIdentities<A, K>(items: readonly A[], identity: (item: A, index: number) => K): K[];
13
13
  export interface Collection<A> {
14
14
  from(this: void, items: readonly (A | Snapshot<A>)[]): Rows<Snapshot<A>>;
15
15
  share<B extends A | Snapshot<A>>(this: void, previous: readonly B[], next: readonly B[]): readonly Snapshot<B>[];
@@ -2,10 +2,8 @@ import { shareData } from './sharing.js';
2
2
  /** Validate without retaining values. Positions are zero-based array indices. */
3
3
  export function validateIdentities(items, identity) {
4
4
  const seen = new Map();
5
- return items.map((item, index) => {
5
+ return Array.from(items, (item, index) => {
6
6
  const key = identity(item, index);
7
- if (typeof key !== 'string' && typeof key !== 'number')
8
- throw new Error(`Invalid collection identity at index ${index}. Use a string or number domain identity, or sequence(items) for positional identity.`);
9
7
  const previous = seen.get(key);
10
8
  if (previous !== undefined)
11
9
  throw new Error(`Duplicate collection identity at indices ${previous} and ${index}. Identity must be unique within the collection; use a composite domain identity when IDs are only locally unique.`);
@@ -4,6 +4,7 @@ import type { Transition } from './program.js';
4
4
  import { type UiRuntime } from './runtime.js';
5
5
  /** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
6
6
  export declare function localComponent<Props, State extends object>(definition: {
7
+ identity?: (props: Snapshot<Props>) => unknown;
7
8
  init: (props: Snapshot<Props>) => (State | Snapshot<State>) & {
8
9
  readonly props?: never;
9
10
  };
@@ -17,6 +18,7 @@ export declare function localComponent<Props, State extends object>(definition:
17
18
  export declare function component<Props, Model extends {
18
19
  readonly props: Props;
19
20
  }, Message, R = never>(definition: {
21
+ identity?: (props: Snapshot<Props>) => unknown;
20
22
  init: (props: Snapshot<Props>) => Model | Snapshot<Model>;
21
23
  receive?: (model: Snapshot<Model>, props: Snapshot<Props>) => Transition<Model, Message, R>;
22
24
  update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
@@ -28,6 +30,7 @@ export declare function component<Props, Model extends {
28
30
  })): View<Props, never>;
29
31
  /** Mount an existing program without introducing a second state owner. */
30
32
  export declare function programView<Props, Model, Message>(definition: {
33
+ identity?: (props: Snapshot<Props>) => unknown;
31
34
  create: (props: Snapshot<Props>) => import('./program').Program<Model, Message>;
32
35
  receive: (source: import('./program').Program<Model, Message>, props: Snapshot<Props>) => void;
33
36
  view: View<Model, Message>;