effectweb 0.2.3 → 0.3.1

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 (77) hide show
  1. package/README.md +93 -1
  2. package/dist/AsyncContent.d.ts +2 -2
  3. package/dist/AsyncContent.js +7 -4
  4. package/dist/actions.d.ts +4 -3
  5. package/dist/actions.js +1 -0
  6. package/dist/boundary.d.ts +11 -0
  7. package/dist/boundary.js +66 -0
  8. package/dist/cache-internals.d.ts +18 -0
  9. package/dist/cache-internals.js +11 -0
  10. package/dist/cache.d.ts +35 -16
  11. package/dist/cache.js +258 -37
  12. package/dist/collection.d.ts +10 -9
  13. package/dist/collection.js +32 -6
  14. package/dist/component.d.ts +11 -7
  15. package/dist/component.js +70 -10
  16. package/dist/diagnostics.d.ts +25 -0
  17. package/dist/diagnostics.js +167 -1
  18. package/dist/dom-attributes.json +71 -0
  19. package/dist/dom.d.ts +44 -15
  20. package/dist/dom.js +493 -89
  21. package/dist/effectEvent.d.ts +2 -1
  22. package/dist/effectEvent.js +4 -1
  23. package/dist/form.d.ts +61 -0
  24. package/dist/form.js +98 -0
  25. package/dist/html-attributes.d.ts +602 -0
  26. package/dist/html-attributes.js +1 -0
  27. package/dist/http.d.ts +5 -0
  28. package/dist/http.js +7 -0
  29. package/dist/index.d.ts +16 -11
  30. package/dist/index.js +11 -6
  31. package/dist/infinite-query.d.ts +42 -0
  32. package/dist/infinite-query.js +150 -0
  33. package/dist/intrinsic.d.ts +2 -0
  34. package/dist/intrinsic.js +120 -0
  35. package/dist/jsx.d.ts +165 -33
  36. package/dist/jsx.js +2 -1
  37. package/dist/keyed-tasks.d.ts +29 -0
  38. package/dist/keyed-tasks.js +97 -0
  39. package/dist/lazy.d.ts +23 -0
  40. package/dist/lazy.js +66 -0
  41. package/dist/load.d.ts +3 -3
  42. package/dist/mount.d.ts +11 -12
  43. package/dist/mount.js +30 -14
  44. package/dist/owner.d.ts +14 -10
  45. package/dist/owner.js +66 -39
  46. package/dist/pages.d.ts +28 -26
  47. package/dist/pages.js +19 -11
  48. package/dist/program.d.ts +29 -9
  49. package/dist/program.js +204 -61
  50. package/dist/query-internals.d.ts +13 -0
  51. package/dist/query-internals.js +10 -0
  52. package/dist/query.d.ts +30 -19
  53. package/dist/query.js +64 -5
  54. package/dist/resource.d.ts +4 -10
  55. package/dist/resource.js +14 -5
  56. package/dist/runtime.d.ts +1 -2
  57. package/dist/runtime.js +3 -3
  58. package/dist/session.d.ts +12 -15
  59. package/dist/session.js +25 -13
  60. package/dist/settlement.d.ts +7 -0
  61. package/dist/settlement.js +27 -0
  62. package/dist/share.d.ts +5 -4
  63. package/dist/share.js +5 -44
  64. package/dist/sharing.d.ts +6 -0
  65. package/dist/sharing.js +96 -0
  66. package/dist/slotIdentity.d.ts +1 -0
  67. package/dist/slotIdentity.js +2 -0
  68. package/dist/snapshot.d.ts +16 -5
  69. package/dist/snapshot.js +34 -27
  70. package/dist/svg-attributes.d.ts +12 -0
  71. package/dist/svg-attributes.js +1 -0
  72. package/dist/task.d.ts +9 -8
  73. package/dist/task.js +75 -47
  74. package/dist/tasks.d.ts +20 -15
  75. package/dist/tasks.js +40 -25
  76. package/dist/testing.d.ts +10 -14
  77. package/package.json +13 -1
package/dist/cache.js CHANGED
@@ -1,35 +1,102 @@
1
- import { Effect, Option } from 'effect';
1
+ import { Settlement } from './settlement.js';
2
+ import { protectSnapshot } from './snapshot.js';
3
+ import { Deferred, Effect, Option } from 'effect';
2
4
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
5
  import * as Atom from 'effect/unstable/reactivity/Atom';
4
6
  import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
5
7
  import { loadEffect } from './load.js';
6
- import { shareValue } from './share.js';
8
+ import { shareData } from './sharing.js';
9
+ import { encodeQueryKey } from './query.js';
10
+ import { registerCache } from './cache-internals.js';
11
+ import { queryDefinition } from './query-internals.js';
7
12
  export { loadEffect } from './load.js';
8
13
  export { shareValue } from './share.js';
9
- export function makeQueryCache(runtime) {
10
- return createQueryCache(runtime);
14
+ export function makeQueryCache(runtimeOrOptions, options = {}) {
15
+ return runtimeOrOptions && 'provide' in runtimeOrOptions
16
+ ? createQueryCache(runtimeOrOptions, options)
17
+ : createQueryCache(undefined, runtimeOrOptions);
11
18
  }
12
- function createQueryCache(runtime) {
13
- const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
19
+ function createQueryCache(runtime, options = {}) {
20
+ const retention = options.retention ?? 30_000;
21
+ if (!Number.isFinite(retention) || retention < 0)
22
+ throw new RangeError('Query retention must be finite and nonnegative.');
23
+ const registry = AtomRegistry.make({ defaultIdleTTL: retention });
24
+ const cancelValue = Symbol('cancel');
25
+ const removeValue = Symbol('remove');
14
26
  const generation = Atom.keepAlive(Atom.make(0));
15
27
  const resources = new Map();
16
- const acquire = (key, load, share = shareValue) => {
28
+ const entriesByAtom = new WeakMap();
29
+ // Follow registry-node lifetime; retaining definitions must not retain evicted data.
30
+ const values = new WeakMap();
31
+ const identities = new WeakMap();
32
+ let disposed = false;
33
+ const settlement = new Settlement();
34
+ let closing;
35
+ let nextId = 0;
36
+ let nextRevision = 0;
37
+ const identity = (definition) => {
38
+ let id = identities.get(definition);
39
+ if (id === undefined) {
40
+ id = ++nextId;
41
+ identities.set(definition, id);
42
+ }
43
+ return id;
44
+ };
45
+ const acquire = (key, load, share = (previous, next) => shareData(previous, next)) => {
17
46
  let entry = resources.get(key);
18
47
  if (entry)
19
48
  entry.load = load;
20
49
  else {
50
+ const loaded = Atom.make((get) => {
51
+ const previous = Option.flatMap(get.self(), AsyncResult.value);
52
+ next.revision = ++nextRevision;
53
+ next.canceled = false;
54
+ const revision = next.revision;
55
+ return Effect.suspend(() => {
56
+ const finish = settlement.begin();
57
+ return loadEffect(() => next.load()).pipe(Effect.map((value) => {
58
+ const shared = Option.isSome(previous)
59
+ ? share(previous.value, value)
60
+ : value;
61
+ const snapshot = protectSnapshot(shared);
62
+ if (!disposed && revision === next.revision)
63
+ remember(next, snapshot);
64
+ return snapshot;
65
+ }), Effect.ensuring(Effect.sync(finish)));
66
+ });
67
+ });
21
68
  const next = {
22
69
  load,
23
- atom: Atom.make((get) => {
24
- const previous = Option.flatMap(get.self(), AsyncResult.value);
25
- return loadEffect(() => next.load()).pipe(Effect.map((value) => {
26
- next.loadedAt = Date.now();
27
- return Option.isSome(previous) ? share(previous.value, value) : value;
28
- }));
70
+ users: 0,
71
+ prefetches: new Set(),
72
+ revision: 0,
73
+ atom: Atom.writable(loaded.read, (context, value) => {
74
+ Atom.batch(() => {
75
+ next.revision = ++nextRevision;
76
+ // Refresh disposes the load lifetime; setSelf publishes without starting another load.
77
+ context.refreshSelf();
78
+ if (value === cancelValue || value === removeValue) {
79
+ const previous = value === removeValue ? undefined : previousValue(next);
80
+ next.canceled = true;
81
+ if (value === removeValue) {
82
+ delete next.loadedAt;
83
+ const node = registry.getNodes().get(next.atom);
84
+ if (node)
85
+ values.delete(node);
86
+ }
87
+ context.setSelf(previous ? AsyncResult.success(previous.value) : AsyncResult.initial());
88
+ }
89
+ else {
90
+ next.canceled = false;
91
+ remember(next, value);
92
+ context.setSelf(AsyncResult.success(value));
93
+ }
94
+ });
29
95
  }),
30
96
  };
31
97
  entry = next;
32
98
  resources.set(key, entry);
99
+ entriesByAtom.set(entry.atom, entry);
33
100
  }
34
101
  // Bound definitions as well as the registry's values. Mounted resources stay shared.
35
102
  if (resources.size > 512) {
@@ -42,62 +109,216 @@ function createQueryCache(runtime) {
42
109
  }
43
110
  return { entry, atom: entry.atom };
44
111
  };
45
- const queryKey = (definition, args) => `query:${definition.id}:${definition.key(args)}`;
46
- const selectQuery = (definition, args) => {
112
+ const remember = (entry, value) => {
113
+ entry.loadedAt = Date.now();
114
+ const node = registry.getNodes().get(entry.atom);
115
+ if (node)
116
+ values.set(node, { value });
117
+ };
118
+ const previousValue = (entry) => {
119
+ const node = entry && registry.getNodes().get(entry.atom);
120
+ return node ? values.get(node) : undefined;
121
+ };
122
+ const checkWritable = () => {
123
+ if (disposed)
124
+ throw new Error('Cannot write to a disposed query cache.');
125
+ };
126
+ const queryKey = (definition, args) => `query:${identity(definition)}:${encodeQueryKey(args)}`;
127
+ const acquireQuery = (definition, args) => {
128
+ const config = queryDefinition(definition);
129
+ protectSnapshot(args);
47
130
  const { entry, atom } = acquire(queryKey(definition, args), () => {
48
- const effect = Effect.suspend(() => definition.load(args));
131
+ const effect = Effect.suspend(() => config.load(args, previousValue(resources.get(queryKey(definition, args)))?.value));
49
132
  return runtime ? runtime.provide(effect) : effect;
50
- }, definition.share);
51
- entry.queryId = definition.id;
133
+ }, config.share);
134
+ entry.query = definition;
135
+ entry.args = args;
136
+ entry.groups = config.groups;
137
+ entry.unused = config.unused ?? options.unused;
138
+ return { entry, atom, config };
139
+ };
140
+ const refresh = (entry) => {
141
+ entry.revision = ++nextRevision;
142
+ registry.refresh(entry.atom);
143
+ };
144
+ const selectQuery = (definition, args) => {
145
+ const { entry, atom, config } = acquireQuery(definition, args);
146
+ if (entry.canceled && !previousValue(entry))
147
+ refresh(entry);
52
148
  if (registry.getNodes().has(atom) &&
53
149
  entry.loadedAt !== undefined &&
54
- Date.now() - entry.loadedAt >= definition.staleTime) {
150
+ Date.now() - entry.loadedAt >= config.staleTime) {
55
151
  const current = registry.get(atom);
56
152
  if (!current.waiting)
57
- registry.refresh(atom);
153
+ refresh(entry);
58
154
  }
59
155
  return atom;
60
156
  };
61
- return {
62
- registry,
63
- generation,
64
- resource(key, load) {
65
- return acquire(key, load).atom;
157
+ const retain = (atom) => {
158
+ const entry = entriesByAtom.get(atom);
159
+ if (!entry)
160
+ return () => { };
161
+ entry.users++;
162
+ let released = false;
163
+ return () => {
164
+ if (released)
165
+ return;
166
+ released = true;
167
+ entry.users--;
168
+ if (!disposed &&
169
+ entry.users === 0 &&
170
+ entry.unused === 'cancel' &&
171
+ registry.getNodes().has(entry.atom) &&
172
+ registry.get(entry.atom).waiting)
173
+ registry.set(entry.atom, cancelValue);
174
+ };
175
+ };
176
+ const abortPrefetches = (entry) => {
177
+ // oxlint-disable-next-line unicorn/no-useless-spread -- Cancellation can add or remove observers reentrantly.
178
+ for (const abort of [...entry.prefetches])
179
+ abort();
180
+ };
181
+ const cache = {
182
+ batch: Atom.batch,
183
+ getQueryData(definition, args) {
184
+ return previousValue(resources.get(queryKey(definition, args)))?.value;
66
185
  },
67
- query: selectQuery,
68
- prefetch(definition, args) {
69
- return Effect.suspend(() => AtomRegistry.getResult(registry, selectQuery(definition, args), { suspendOnWaiting: true }));
186
+ invalidateWhere(definition, predicate) {
187
+ const selected = [...resources.values()].filter((entry) => entry.query === definition && predicate(entry.args));
188
+ Atom.batch(() => {
189
+ for (const entry of selected)
190
+ refresh(entry);
191
+ });
192
+ },
193
+ invalidateGroup(group) {
194
+ Atom.batch(() => {
195
+ for (const entry of resources.values())
196
+ if (entry.groups?.includes(group))
197
+ refresh(entry);
198
+ });
199
+ },
200
+ cancelQuery(definition, ...selected) {
201
+ Atom.batch(() => {
202
+ for (const [key, entry] of resources)
203
+ if (entry.query === definition &&
204
+ (!selected.length || key === queryKey(definition, selected[0])))
205
+ if (registry.getNodes().has(entry.atom) && registry.get(entry.atom).waiting) {
206
+ abortPrefetches(entry);
207
+ registry.set(entry.atom, cancelValue);
208
+ }
209
+ });
210
+ },
211
+ removeQuery(definition, ...selected) {
212
+ Atom.batch(() => {
213
+ for (const [key, entry] of resources)
214
+ if (entry.query === definition &&
215
+ (!selected.length || key === queryKey(definition, selected[0])))
216
+ if (registry.getNodes().has(entry.atom)) {
217
+ abortPrefetches(entry);
218
+ registry.set(entry.atom, removeValue);
219
+ }
220
+ });
221
+ },
222
+ prefetch(definition, args, options) {
223
+ return Effect.suspend(() => {
224
+ checkWritable();
225
+ const atom = selectQuery(definition, args);
226
+ const release = retain(atom);
227
+ const entry = entriesByAtom.get(atom);
228
+ const signal = Deferred.makeUnsafe();
229
+ const abort = () => {
230
+ Deferred.doneUnsafe(signal, Effect.interrupt);
231
+ };
232
+ entry.prefetches.add(abort);
233
+ return Effect.suspend(() => {
234
+ if (options?.refresh && registry.getNodes().has(atom) && !registry.get(atom).waiting)
235
+ refresh(entry);
236
+ return Effect.raceFirst(Deferred.await(signal), AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }));
237
+ }).pipe(Effect.ensuring(Effect.sync(() => {
238
+ entry.prefetches.delete(abort);
239
+ release();
240
+ })));
241
+ });
242
+ },
243
+ setQueryData(definition, args, value) {
244
+ checkWritable();
245
+ const { entry, config } = acquireQuery(definition, args);
246
+ const previous = previousValue(entry);
247
+ const next = protectSnapshot(value);
248
+ const shared = previous
249
+ ? config.share
250
+ ? config.share(previous.value, next)
251
+ : shareData(previous.value, next)
252
+ : next;
253
+ const snapshot = protectSnapshot(shared);
254
+ checkWritable();
255
+ registry.set(entry.atom, snapshot);
256
+ return snapshot;
257
+ },
258
+ updateQueryData(definition, args, update) {
259
+ checkWritable();
260
+ queryDefinition(definition);
261
+ protectSnapshot(args);
262
+ const previous = previousValue(resources.get(queryKey(definition, args)));
263
+ if (!previous)
264
+ return undefined;
265
+ const next = update(previous.value);
266
+ return next === undefined ? undefined : cache.setQueryData(definition, args, next);
70
267
  },
71
268
  invalidateQuery(definition, ...selected) {
72
269
  if (selected.length) {
73
270
  const entry = resources.get(queryKey(definition, selected[0]));
74
271
  if (entry)
75
- registry.refresh(entry.atom);
272
+ refresh(entry);
76
273
  }
77
274
  else {
78
- for (const entry of resources.values())
79
- if (entry.queryId === definition.id)
80
- registry.refresh(entry.atom);
275
+ cache.invalidateWhere(definition, () => true);
81
276
  }
82
277
  },
83
- invalidate(prefix) {
84
- for (const [key, entry] of resources)
85
- if (key.startsWith(prefix))
86
- registry.refresh(entry.atom);
278
+ onReset(listener) {
279
+ registry.get(generation);
280
+ return registry.subscribe(generation, listener);
87
281
  },
88
282
  resetResources() {
89
283
  Atom.batch(() => {
90
284
  for (const entry of resources.values()) {
285
+ abortPrefetches(entry);
91
286
  entry.load = () => Effect.interrupt;
92
- registry.refresh(entry.atom);
287
+ refresh(entry);
93
288
  }
94
289
  resources.clear();
95
290
  registry.update(generation, (value) => value + 1);
96
291
  });
97
292
  },
293
+ close() {
294
+ if (!closing) {
295
+ cache.dispose();
296
+ closing = settlement.wait();
297
+ }
298
+ return closing;
299
+ },
98
300
  dispose() {
301
+ if (disposed)
302
+ return;
303
+ disposed = true;
304
+ for (const entry of resources.values())
305
+ abortPrefetches(entry);
99
306
  registry.dispose();
100
307
  resources.clear();
101
308
  },
102
309
  };
310
+ registerCache(cache, {
311
+ disposed: () => disposed,
312
+ refresh: (atom) => {
313
+ const entry = entriesByAtom.get(atom);
314
+ if (entry)
315
+ refresh(entry);
316
+ },
317
+ registry,
318
+ generation,
319
+ query: selectQuery,
320
+ retain,
321
+ revision: (definition, args) => resources.get(queryKey(definition, args))?.revision,
322
+ });
323
+ return Object.freeze(cache);
103
324
  }
@@ -1,3 +1,4 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  export type Identity = string | number;
2
3
  export interface Rows<A> {
3
4
  readonly items: readonly A[];
@@ -7,16 +8,16 @@ export interface Rows<A> {
7
8
  filter(predicate: (item: A, index: number) => boolean): Rows<A>;
8
9
  slice(start?: number, end?: number): Rows<A>;
9
10
  }
10
- export declare function collection<A>(identity: (item: A, index: number) => Identity): {
11
- from: (items: readonly A[]) => Rows<A>;
12
- share: {
13
- <B extends A>(previous: readonly B[], next: B[]): B[];
14
- <B extends A>(previous: readonly B[], next: readonly B[]): readonly B[];
15
- };
16
- };
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[];
13
+ export interface Collection<A> {
14
+ from(this: void, items: readonly (A | Snapshot<A>)[]): Rows<Snapshot<A>>;
15
+ share<B extends A | Snapshot<A>>(this: void, previous: readonly B[], next: readonly B[]): readonly Snapshot<B>[];
16
+ }
17
+ export declare function collection<A>(identity: (item: Snapshot<A>, index: number) => Identity): Collection<A>;
17
18
  /** Use positional identity for ordered values without stable entity IDs. */
18
- export declare function sequence<A>(items: readonly A[]): Rows<A>;
19
+ export declare function sequence<A>(items: readonly A[]): Rows<Snapshot<A>>;
19
20
  /** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
20
21
  export declare function entities<A extends {
21
22
  readonly id: Identity;
22
- }>(items: readonly A[] | undefined): Rows<A>;
23
+ }>(items: readonly A[] | undefined): Rows<Snapshot<A>>;
@@ -1,8 +1,30 @@
1
- import { shareValue } from './share.js';
2
- export function collection(identity) {
1
+ import { shareData } from './sharing.js';
2
+ /** Validate without retaining values. Positions are zero-based array indices. */
3
+ export function validateIdentities(items, identity) {
4
+ const seen = new Map();
5
+ return items.map((item, index) => {
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
+ const previous = seen.get(key);
10
+ if (previous !== undefined)
11
+ 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.`);
12
+ seen.set(key, index);
13
+ return key;
14
+ });
15
+ }
16
+ function makeCollection(identity) {
3
17
  const cache = new WeakMap();
18
+ const validated = new WeakSet();
19
+ const validate = (items) => {
20
+ if (validated.has(items))
21
+ return;
22
+ validateIdentities(items, identity);
23
+ validated.add(items);
24
+ };
4
25
  const comparisons = new WeakMap();
5
26
  const from = (items) => {
27
+ validate(items);
6
28
  const cached = cache.get(items);
7
29
  if (cached)
8
30
  return cached;
@@ -18,6 +40,8 @@ export function collection(identity) {
18
40
  return rows;
19
41
  };
20
42
  function share(previous, next) {
43
+ validate(previous);
44
+ validate(next);
21
45
  if (previous === next)
22
46
  return previous;
23
47
  let pairs = comparisons.get(previous);
@@ -36,13 +60,10 @@ export function collection(identity) {
36
60
  if (index >= previous.length || !Object.is(identity(old, index), key)) {
37
61
  if (!byIdentity) {
38
62
  byIdentity = new Map(previous.map((value, i) => [identity(value, i), value]));
39
- const keys = next.map(identity);
40
- if (byIdentity.size !== previous.length || new Set(keys).size !== keys.length)
41
- throw new Error('Duplicate collection identity. Identity must be unique within the collection.');
42
63
  }
43
64
  old = byIdentity.get(key);
44
65
  }
45
- const value = old === undefined ? item : shareValue(old, item);
66
+ const value = old === undefined ? item : shareData(old, item);
46
67
  if (!Object.is(value, previous[index]))
47
68
  equal = false;
48
69
  if (!Object.is(value, item) && !result)
@@ -60,6 +81,11 @@ export function collection(identity) {
60
81
  }
61
82
  return { from, share };
62
83
  }
84
+ export function collection(identity) {
85
+ // Snapshot changes access permissions, not runtime representation. The implementation
86
+ // only borrows supplied items, and never inserts values of a wider type.
87
+ return makeCollection(identity);
88
+ }
63
89
  const positions = collection((_item, index) => index);
64
90
  /** Use positional identity for ordered values without stable entity IDs. */
65
91
  export function sequence(items) {
@@ -1,14 +1,16 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  import type { View } from './dom.js';
2
3
  import type { Transition } from './program.js';
3
4
  import { type UiRuntime } from './runtime.js';
4
5
  /** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
5
6
  export declare function localComponent<Props, State extends object>(definition: {
6
- init: (props: Props) => State & {
7
+ identity?: (props: Snapshot<Props>) => unknown;
8
+ init: (props: Snapshot<Props>) => (State | Snapshot<State>) & {
7
9
  readonly props?: never;
8
10
  };
9
11
  view: View<State & {
10
12
  readonly props: Props;
11
- }, Partial<State> & {
13
+ }, (Partial<State> | Partial<Snapshot<State>>) & {
12
14
  readonly props?: never;
13
15
  }>;
14
16
  }): View<Props, never>;
@@ -16,9 +18,10 @@ export declare function localComponent<Props, State extends object>(definition:
16
18
  export declare function component<Props, Model extends {
17
19
  readonly props: Props;
18
20
  }, Message, R = never>(definition: {
19
- init: (props: Props) => Model;
20
- receive?: (model: Model, props: Props) => Transition<Model, Message, R>;
21
- update: (model: Model, message: Message) => Transition<Model, Message, R>;
21
+ identity?: (props: Snapshot<Props>) => unknown;
22
+ init: (props: Snapshot<Props>) => Model | Snapshot<Model>;
23
+ receive?: (model: Snapshot<Model>, props: Snapshot<Props>) => Transition<Model, Message, R>;
24
+ update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
22
25
  view: View<Model, Message>;
23
26
  } & ([R] extends [never] ? {
24
27
  runtime?: UiRuntime<R>;
@@ -27,7 +30,8 @@ export declare function component<Props, Model extends {
27
30
  })): View<Props, never>;
28
31
  /** Mount an existing program without introducing a second state owner. */
29
32
  export declare function programView<Props, Model, Message>(definition: {
30
- create: (props: Props) => import('./program').Program<Model, Message>;
31
- receive: (source: import('./program').Program<Model, Message>, props: Props) => void;
33
+ identity?: (props: Snapshot<Props>) => unknown;
34
+ create: (props: Snapshot<Props>) => import('./program').Program<Model, Message>;
35
+ receive: (source: import('./program').Program<Model, Message>, props: Snapshot<Props>) => void;
32
36
  view: View<Model, Message>;
33
37
  }): View<Props, never>;
package/dist/component.js CHANGED
@@ -1,10 +1,12 @@
1
- import { compiled, Scope } from './dom.js';
1
+ import { compiled, Scope, viewRegion } from './dom.js';
2
2
  import { mapCommand, program } from './program.js';
3
3
  import { patchModel } from './state.js';
4
+ import { reportSafely } from './errors.js';
4
5
  import { defaultUiRuntime } from './runtime.js';
5
6
  /** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
6
7
  export function localComponent(definition) {
7
8
  return component({
9
+ ...(definition.identity ? { identity: definition.identity } : {}),
8
10
  init: (props) => ({ ...definition.init(props), props }),
9
11
  update: (model, patch) => {
10
12
  const next = patchModel(model, patch);
@@ -16,7 +18,7 @@ export function localComponent(definition) {
16
18
  /** A child model receives immutable parent inputs and owns its own message/command loop. */
17
19
  export function component(definition) {
18
20
  const runtime = definition.runtime ?? defaultUiRuntime;
19
- return compiled((scope, parent, before) => {
21
+ return identify(compiled((scope, parent, before) => {
20
22
  const wrap = (next) => ({
21
23
  model: next.model,
22
24
  ...(next.cancel ? { cancel: next.cancel } : {}),
@@ -37,22 +39,80 @@ export function component(definition) {
37
39
  })
38
40
  : definition.update(model, envelope.message)),
39
41
  });
40
- const child = new Scope(source.model(), (message) => source.send({ type: 'Message', message }), scope.report);
41
- scope.cleanups.push(() => source.dispose(), () => child.dispose(), () => unsubscribe());
42
- const unsubscribe = source.subscribe((model) => child.set(model));
42
+ if (scope.disposed) {
43
+ closeProgram(scope, source);
44
+ return;
45
+ }
46
+ const child = new Scope(source.model(), (message) => source.send({ type: 'Message', message }), scope.report, scope.settlement);
47
+ let unsubscribe = () => { };
48
+ scope.cleanups.push(() => closeProgram(scope, source), () => child.dispose(), () => unsubscribe());
49
+ const release = source.subscribe((model) => child.set(model));
50
+ if (scope.disposed) {
51
+ release();
52
+ return;
53
+ }
54
+ unsubscribe = release;
43
55
  definition.view.build(child, parent, before);
44
56
  scope.jobs.push(() => source.send({ type: 'Input', props: scope.value }));
45
57
  source.send({ type: 'Input', props: scope.value });
46
- });
58
+ }), definition.identity);
47
59
  }
48
60
  /** Mount an existing program without introducing a second state owner. */
49
61
  export function programView(definition) {
50
- return compiled((scope, parent, before) => {
62
+ return identify(compiled((scope, parent, before) => {
51
63
  const source = definition.create(scope.value);
52
- const child = new Scope(source.model(), source.send, scope.report);
53
- const unsubscribe = source.subscribe((model) => child.set(model));
54
- scope.cleanups.push(() => source.dispose(), () => child.dispose(), () => unsubscribe());
64
+ if (scope.disposed) {
65
+ closeProgram(scope, source);
66
+ return;
67
+ }
68
+ const child = new Scope(source.model(), source.send, scope.report, scope.settlement);
69
+ let unsubscribe = () => { };
70
+ scope.cleanups.push(() => closeProgram(scope, source), () => child.dispose(), () => unsubscribe());
71
+ const release = source.subscribe((model) => child.set(model));
72
+ if (scope.disposed) {
73
+ release();
74
+ return;
75
+ }
76
+ unsubscribe = release;
55
77
  definition.view.build(child, parent, before);
56
78
  scope.jobs.push(() => definition.receive(source, scope.value));
79
+ }), definition.identity);
80
+ }
81
+ function closeProgram(scope, source) {
82
+ const finish = scope.settlement.begin();
83
+ try {
84
+ if (source.close) {
85
+ source.close().then(finish, (error) => {
86
+ finish();
87
+ reportSafely(scope.report, error);
88
+ });
89
+ }
90
+ else {
91
+ source.dispose();
92
+ finish();
93
+ }
94
+ }
95
+ catch (error) {
96
+ finish();
97
+ throw error;
98
+ }
99
+ }
100
+ function identify(definition, identity) {
101
+ if (!identity)
102
+ return definition;
103
+ return compiled((scope, parent, before) => {
104
+ const render = viewRegion(scope, parent, before, { manual: true });
105
+ let current;
106
+ let active;
107
+ const update = () => {
108
+ const next = identity(scope.value);
109
+ if (!active || !Object.is(current, next)) {
110
+ current = next;
111
+ active = compiled(definition.build);
112
+ }
113
+ render(active);
114
+ };
115
+ scope.jobs.push(update);
116
+ update();
57
117
  });
58
118
  }
@@ -10,6 +10,7 @@ export interface BindingUpdate {
10
10
  readonly source: BindingSource;
11
11
  readonly changed: readonly string[];
12
12
  readonly kind: 'derive' | 'binding';
13
+ readonly reason: 'initial' | 'dependencies';
13
14
  }
14
15
  /** Each registration has its own lifetime; observers can be stopped in any order. */
15
16
  export declare function observeBindings(next: (update: BindingUpdate) => void): () => void;
@@ -30,3 +31,27 @@ export declare function observePrograms(limit?: number): {
30
31
  events: () => ProgramUpdate[];
31
32
  dispose: () => boolean;
32
33
  };
34
+ /** Counts aggregate all mounted instances of a source expression. */
35
+ export interface BindingInspection {
36
+ readonly source: BindingSource;
37
+ readonly derives: number;
38
+ readonly bindings: number;
39
+ readonly changed: readonly string[];
40
+ readonly reason: BindingUpdate['reason'];
41
+ }
42
+ export interface BindingInspector {
43
+ /** Most recently updated first; bounded by limit (default 200, maximum 1000). */
44
+ entries(): readonly BindingInspection[];
45
+ subscribe(next: () => void): () => void;
46
+ clear(): void;
47
+ /** Unsubscribe and release all metadata and listeners. */
48
+ dispose(): void;
49
+ }
50
+ /** Development metadata only: never reads or retains models, values, or DOM nodes. */
51
+ export declare function inspectBindings(options?: {
52
+ readonly limit?: number;
53
+ }): BindingInspector;
54
+ /** Mount a live source inspector. Mount before the application to include initial evaluations.
55
+ * The returned cleanup removes the panel; an externally supplied inspector keeps its lifetime.
56
+ */
57
+ export declare function mountBindingInspector(target: HTMLElement, inspector?: BindingInspector): () => void;