effectweb 0.3.1 → 0.5.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 (66) hide show
  1. package/dist/AsyncContent.d.ts +1 -1
  2. package/dist/AsyncContent.js +18 -35
  3. package/dist/cache-internals.d.ts +3 -2
  4. package/dist/cache.d.ts +15 -12
  5. package/dist/cache.js +51 -10
  6. package/dist/collection.d.ts +1 -1
  7. package/dist/collection.js +1 -3
  8. package/dist/component.d.ts +1 -1
  9. package/dist/component.js +9 -4
  10. package/dist/dom.d.ts +64 -31
  11. package/dist/dom.js +237 -121
  12. package/dist/effectEvent.d.ts +10 -7
  13. package/dist/effectEvent.js +50 -46
  14. package/dist/form.d.ts +2 -1
  15. package/dist/http.d.ts +2 -1
  16. package/dist/http.js +1 -1
  17. package/dist/index.d.ts +10 -8
  18. package/dist/index.js +10 -8
  19. package/dist/infinite-query.d.ts +12 -8
  20. package/dist/infinite-query.js +21 -13
  21. package/dist/jsx-runtime.d.ts +6 -0
  22. package/dist/jsx-runtime.js +14 -1
  23. package/dist/jsx.d.ts +4 -1
  24. package/dist/keyed-tasks.d.ts +8 -6
  25. package/dist/keyed-tasks.js +29 -24
  26. package/dist/lazy.d.ts +4 -2
  27. package/dist/lazy.js +16 -10
  28. package/dist/load.d.ts +1 -1
  29. package/dist/load.js +1 -1
  30. package/dist/mount.d.ts +23 -8
  31. package/dist/mount.js +116 -27
  32. package/dist/owner.d.ts +13 -8
  33. package/dist/owner.js +37 -41
  34. package/dist/pages.d.ts +1 -1
  35. package/dist/pages.js +2 -1
  36. package/dist/program.d.ts +20 -8
  37. package/dist/program.js +44 -22
  38. package/dist/query-internals.d.ts +5 -3
  39. package/dist/query.d.ts +17 -6
  40. package/dist/query.js +6 -3
  41. package/dist/render.d.ts +17 -0
  42. package/dist/render.js +48 -0
  43. package/dist/resource.d.ts +4 -3
  44. package/dist/resource.js +12 -6
  45. package/dist/runtime.d.ts +12 -1
  46. package/dist/runtime.js +27 -5
  47. package/dist/session.d.ts +3 -2
  48. package/dist/session.js +15 -5
  49. package/dist/settlement.d.ts +8 -1
  50. package/dist/settlement.js +20 -8
  51. package/dist/snapshot.d.ts +1 -1
  52. package/dist/snapshot.js +10 -12
  53. package/dist/source.d.ts +38 -0
  54. package/dist/source.js +218 -0
  55. package/dist/task.d.ts +1 -1
  56. package/dist/task.js +3 -1
  57. package/dist/tasks.d.ts +9 -6
  58. package/dist/tasks.js +7 -4
  59. package/dist/testing.d.ts +5 -4
  60. package/dist/testing.js +4 -2
  61. package/package.json +10 -3
  62. package/README.md +0 -101
  63. package/dist/intrinsic.d.ts +0 -2
  64. package/dist/intrinsic.js +0 -120
  65. package/dist/slotIdentity.d.ts +0 -1
  66. package/dist/slotIdentity.js +0 -2
package/dist/dom.js CHANGED
@@ -1,13 +1,15 @@
1
- import { compiledSlots } from './slotIdentity.js';
2
1
  import attributeData from './dom-attributes.json' with { type: 'json' };
2
+ import * as Effect from 'effect/Effect';
3
+ import * as Exit from 'effect/Exit';
4
+ import { protectSnapshot } from './snapshot.js';
3
5
  import { validateIdentities } from './collection.js';
4
6
  import { runAll, reportError, reportSafely } from './errors.js';
5
7
  import { eventEffects } from './effectEvent.js';
6
8
  import { traceBinding } from './diagnostics.js';
7
- import { shareData } from './sharing.js';
8
9
  import { jsxComponent } from './jsx.js';
9
10
  import { Settlement } from './settlement.js';
10
- import { startMount } from './mount.js';
11
+ import { prepareMount } from './mount.js';
12
+ const bindingLocation = (source) => typeof source === 'function' ? source() : source;
11
13
  const equal = (a, b) => a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
12
14
  // A model publication reconciles DOM first and controlled dependent properties second.
13
15
  // Nested child scopes join the same synchronous commit; there is no reactive graph.
@@ -42,41 +44,23 @@ export class Scope {
42
44
  jobs = [];
43
45
  cleanups = [];
44
46
  disposed = false;
45
- revision = 0;
46
47
  constructor(value, send, report = reportError, settlement = new Settlement()) {
47
48
  this.value = value;
48
49
  this.send = send;
49
50
  this.report = report;
50
51
  this.settlement = settlement;
51
52
  }
52
- derive(dependencies, compute, source) {
53
- let previous;
54
- let value;
55
- let revision = -1;
56
- return () => {
57
- if (revision === this.revision)
58
- return value;
59
- const next = dependencies();
60
- if (!previous || !equal(previous, next)) {
61
- value = previous ? shareData(value, compute()) : compute();
62
- traceBinding(source, previous, next, 'derive');
63
- previous = next;
64
- }
65
- revision = this.revision;
66
- return value;
67
- };
68
- }
69
53
  watch(dependencies, apply, source) {
70
54
  let previous = dependencies();
71
55
  apply();
72
- traceBinding(source, undefined, previous, 'binding');
56
+ traceBinding(bindingLocation(source), undefined, previous, 'binding');
73
57
  if (!previous.length)
74
58
  return;
75
59
  const run = () => {
76
60
  const next = dependencies();
77
61
  if (!equal(previous, next)) {
78
62
  apply();
79
- traceBinding(source, previous, next, 'binding');
63
+ traceBinding(bindingLocation(source), previous, next, 'binding');
80
64
  previous = next;
81
65
  }
82
66
  };
@@ -87,7 +71,6 @@ export class Scope {
87
71
  return;
88
72
  commitDom(() => {
89
73
  this.value = value;
90
- this.revision++;
91
74
  for (const job of this.jobs) {
92
75
  if (this.disposed)
93
76
  break;
@@ -108,18 +91,18 @@ export class Scope {
108
91
  runAll(this.cleanups.splice(0).reverse(), this.report);
109
92
  }
110
93
  }
111
- /** Compiler marker for mounting a view with an explicit model and message dispatcher. */
112
- export const ViewBinding = /* @__PURE__ */ Object.assign(function ViewBinding(_props) {
113
- throw new Error('ViewBinding reached runtime without the EffectWeb JSX compiler');
94
+ /** Mount a view with an explicit model and message dispatcher. */
95
+ export const ViewBinding = /* @__PURE__ */ Object.assign(function ViewBinding(props) {
96
+ return new SlotPlacement(viewContent(props.view), { model: props.model, send: props.send });
114
97
  }, { [jsxComponent]: true });
115
98
  /** Untyped callers get an actionable failure instead of silently losing child messages. */
116
99
  export function unboundSend(_message) {
117
100
  throw new Error('This view needs a dispatcher. Mount it through ViewBinding with model and send.');
118
101
  }
119
102
  const contentBrand = Symbol('compiled content');
120
- /** Compiler marker: declare inside view(), or directly in a compiled component prop. */
121
- export function slot(_render) {
122
- throw new Error('EffectWeb slot reached runtime without the EffectWeb JSX compiler');
103
+ /* @__NO_SIDE_EFFECTS__ */
104
+ export function slot(render) {
105
+ return render;
123
106
  }
124
107
  // Placement values are opaque to structural sharing, which only traverses plain data.
125
108
  class SlotPlacement {
@@ -136,62 +119,152 @@ function isContent(value) {
136
119
  (typeof value === 'object' || typeof value === 'function') &&
137
120
  contentBrand in value);
138
121
  }
139
- /** Compiler output. Captures follow their declaring scope; placements own independent scopes. */
140
- export function compiledSlot(owner, build) {
141
- const placements = new Set();
142
- owner.jobs.push(() => {
143
- for (const scope of placements)
144
- scope.set(scope.value);
122
+ /** Evaluate ordinary synchronous JavaScript for each immutable model publication. */
123
+ /* @__NO_SIDE_EFFECTS__ */
124
+ export function view(render) {
125
+ return compiled((scope, parent, before) => {
126
+ const read = () => render(scope.value, scope.send);
127
+ text(scope, parent, before, () => [scope.value], read);
145
128
  });
146
- owner.cleanups.push(() => {
147
- for (const scope of placements)
148
- scope.dispose();
149
- placements.clear();
129
+ }
130
+ /* @__NO_SIDE_EFFECTS__ */
131
+ export function compiled(build) {
132
+ const definition = Object.assign((props) => new SlotPlacement(viewContent(definition), { model: props, send: unboundSend }), { build, [jsxComponent]: true });
133
+ return definition;
134
+ }
135
+ /** Skip a view only under the caller's explicit equality contract. */
136
+ export function memoView(definition, equals) {
137
+ return compiled((scope, parent, before) => {
138
+ const child = new Scope(scope.value, scope.send, scope.report, scope.settlement);
139
+ scope.cleanups.push(() => child.dispose());
140
+ definition.build(child, parent, before);
141
+ scope.jobs.push(() => {
142
+ if (!equals(child.value, scope.value))
143
+ child.set(scope.value);
144
+ });
150
145
  });
151
- const definition = {
146
+ }
147
+ const viewDefinitions = new WeakMap();
148
+ function viewContent(definition) {
149
+ let content = viewDefinitions.get(definition);
150
+ if (!content) {
151
+ content = contentDefinition((scope, parent, before) => {
152
+ const childScope = new Scope(scope.value.model, (message) => scope.value.send(message), scope.report, scope.settlement);
153
+ scope.cleanups.push(() => childScope.dispose());
154
+ definition.build(childScope, parent, before);
155
+ scope.jobs.push(() => childScope.set(scope.value.model));
156
+ });
157
+ viewDefinitions.set(definition, content);
158
+ }
159
+ return content;
160
+ }
161
+ function contentDefinition(build) {
162
+ return {
152
163
  mount(parent, before, value, report, settlement) {
153
- if (owner.disposed)
154
- throw new Error('Cannot mount content after its declaring view disposed');
155
- const scope = new Scope(value, owner.send, report, settlement);
164
+ const scope = new Scope(value, unboundSend, report, settlement);
156
165
  const fragment = buildFragment(parent);
157
166
  const range = markers(fragment, null);
158
167
  scope.cleanups.push(() => remove(range.start, range.end));
159
168
  try {
160
169
  build(scope, fragment, range.end);
161
- parent.insertBefore(fragment, before);
162
- placements.add(scope);
170
+ if (!scope.disposed)
171
+ parent.insertBefore(fragment, before);
163
172
  }
164
173
  catch (error) {
165
174
  scope.dispose();
166
175
  throw error;
167
176
  }
168
- return {
169
- set(value) {
170
- if (!Object.is(scope.value, value))
171
- scope.set(value);
172
- },
173
- dispose() {
174
- placements.delete(scope);
175
- scope.dispose();
176
- },
177
- };
177
+ return { set: (value) => scope.set(value), dispose: () => scope.dispose() };
178
178
  },
179
179
  };
180
- const slot = Object.assign((value) => new SlotPlacement(definition, value), {
181
- [contentBrand]: true,
182
- definition,
180
+ }
181
+ const markupDefinitions = new Map();
182
+ /** JSX factories preserve host identity by element type; source locations are metadata only. */
183
+ export function markup(tag, sources) {
184
+ let definition = markupDefinitions.get(tag);
185
+ if (!definition) {
186
+ definition = contentDefinition((scope, parent, before) => {
187
+ const node = element(parent, before, tag);
188
+ bindAttributes(scope, node, () => [scope.value.props], () => {
189
+ const { children: _children, ...attributes } = scope.value.props;
190
+ return attributes;
191
+ }, () => scope.value.sources);
192
+ text(scope, node, null, () => [scope.value.props.children], () => scope.value.props.children, () => scope.value.sources?.children);
193
+ });
194
+ markupDefinitions.set(tag, definition);
195
+ }
196
+ return (props) => new SlotPlacement(definition, { props, sources });
197
+ }
198
+ export function renderComponent(component, props) {
199
+ return component(props);
200
+ }
201
+ const listDefinition = contentDefinition((scope, parent, before) => {
202
+ each(scope, parent, before, () => scope.value.rows, () => [scope.value.render], true, (row, parent, before) => {
203
+ // The explicit list operation owns callback evaluation and keyed row lifetimes.
204
+ const update = () => scope.value.render(row.value[0], row.value[1]);
205
+ text(row, parent, before, () => [row.value, scope.value.render], update);
183
206
  });
184
- compiledSlots.add(slot);
185
- return slot;
207
+ });
208
+ /** Preserve collection identity (or array reference identity) while rendering rows. */
209
+ export function list(rows, render) {
210
+ return new SlotPlacement(listDefinition, { rows, render });
186
211
  }
187
- /** This declaration is a compiler marker, never a component setup callback. */
188
- export function view(_render) {
189
- throw new Error('EffectWeb view reached runtime without the EffectWeb JSX compiler');
212
+ /**
213
+ * Define keyed row projection once. Every dependency is passed as an explicit input;
214
+ * only changed projected props enter the row view's renderer.
215
+ */
216
+ export function listView(options) {
217
+ const equals = options.equals ?? Object.is;
218
+ const definition = contentDefinition((scope, parent, before) => {
219
+ each(scope, parent, before, () => scope.value.rows, () => [scope.value.input, scope.value.send], true, (row, parent, before) => {
220
+ const model = () => protectSnapshot(options.project(row.value[0], row.value[1], scope.value.input));
221
+ const child = new Scope(model(), (message) => scope.value.send(message), scope.report, scope.settlement);
222
+ row.cleanups.push(() => child.dispose());
223
+ options.view.build(child, parent, before);
224
+ row.jobs.push(() => {
225
+ const next = model();
226
+ if (!equals(child.value, next))
227
+ child.set(next);
228
+ });
229
+ });
230
+ });
231
+ return (rows, input, ...dispatch) => new SlotPlacement(definition, { rows, input, send: dispatch[0] ?? unboundSend });
190
232
  }
191
- export function compiled(build) {
192
- return Object.assign(() => {
193
- throw new Error('Mount compiled views with mountView or inside another compiled view');
194
- }, { build, [jsxComponent]: true });
233
+ const observationDefinition = contentDefinition((scope, parent, before) => {
234
+ let unsubscribe = () => { };
235
+ let source = scope.value.source;
236
+ const child = new Scope(source.model(), unboundSend, scope.report, scope.settlement);
237
+ scope.cleanups.push(() => child.dispose(), () => unsubscribe());
238
+ text(child, parent, before, () => [child.value, scope.value.render], () => scope.value.render(child.value));
239
+ const connect = () => {
240
+ if (scope.disposed)
241
+ return;
242
+ const current = source;
243
+ const release = current.subscribe((value) => {
244
+ if (!scope.disposed && source === current)
245
+ child.set(value);
246
+ });
247
+ if (scope.disposed || source !== current)
248
+ release();
249
+ else {
250
+ unsubscribe = release;
251
+ child.set(current.model());
252
+ }
253
+ };
254
+ connect();
255
+ scope.jobs.push(() => {
256
+ if (source !== scope.value.source) {
257
+ unsubscribe();
258
+ source = scope.value.source;
259
+ connect();
260
+ }
261
+ else
262
+ child.set(source.model());
263
+ });
264
+ });
265
+ /** Render a declared source in its own subscribed region. No dependencies are inferred. */
266
+ export function observe(source, render) {
267
+ return new SlotPlacement(observationDefinition, { source, render });
195
268
  }
196
269
  /** Owned content in an element target, defaulting to the document body. */
197
270
  export const Portal = /* @__PURE__ */ compiled((scope) => {
@@ -259,31 +332,55 @@ function buildFragment(parent) {
259
332
  return fragment;
260
333
  }
261
334
  export function mountView(parent, definition, source, options = {}) {
335
+ return mountViewWithSettlement(parent, definition, source, { ...options, send: options.send ?? source.send ?? unboundSend }, new Settlement(options.runtime));
336
+ }
337
+ /** Internal mounting boundary. Its owner retains completion accounting if construction fails. */
338
+ export function mountViewWithSettlement(parent, definition, source, options, settlement) {
262
339
  return commitDom(() => {
340
+ const initial = source.model();
263
341
  const { start, end } = markers(parent, null);
264
- const scope = new Scope(source.model(), source.send, options.onError);
342
+ const scope = new Scope(initial, options.send, options.onError, settlement);
265
343
  let unsubscribe = () => { };
344
+ let ready = false;
345
+ let latest = scope.value;
266
346
  try {
347
+ unsubscribe = source.subscribe((model) => {
348
+ latest = model;
349
+ if (ready)
350
+ scope.set(model);
351
+ });
267
352
  const fragment = buildFragment(parent);
268
353
  definition.build(scope, fragment, null);
269
354
  parent.insertBefore(fragment, end);
270
- unsubscribe = source.subscribe((model) => scope.set(model));
355
+ ready = true;
356
+ if (!Object.is(latest, scope.value))
357
+ scope.set(latest);
271
358
  }
272
359
  catch (error) {
273
- scope.dispose();
274
- remove(start, end);
360
+ settlement.exit = Exit.die(error);
361
+ runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
275
362
  throw error;
276
363
  }
364
+ let disposed = false;
277
365
  const dispose = () => {
278
- if (scope.disposed)
366
+ if (disposed)
279
367
  return;
280
- runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
368
+ disposed = true;
369
+ const finish = settlement.begin();
370
+ try {
371
+ runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
372
+ }
373
+ finally {
374
+ finish();
375
+ }
281
376
  };
282
377
  return Object.assign(dispose, {
283
378
  dispose,
284
379
  close: () => {
285
- dispose();
286
- return scope.settlement.wait();
380
+ return Effect.suspend(() => {
381
+ dispose();
382
+ return scope.settlement.wait();
383
+ });
287
384
  },
288
385
  });
289
386
  });
@@ -385,7 +482,7 @@ export function bindAttribute(scope, element, name, dependencies, read, source)
385
482
  let previous = dependencies();
386
483
  let value = read();
387
484
  attribute(element, name, value);
388
- traceBinding(source, undefined, previous, 'binding');
485
+ traceBinding(bindingLocation(source), undefined, previous, 'binding');
389
486
  if (!previous.length)
390
487
  return;
391
488
  // Keep the value cache in the dependency job itself, without an extra closure
@@ -399,7 +496,7 @@ export function bindAttribute(scope, element, name, dependencies, read, source)
399
496
  attribute(element, name, nextValue);
400
497
  value = nextValue;
401
498
  }
402
- traceBinding(source, previous, next, 'binding');
499
+ traceBinding(bindingLocation(source), previous, next, 'binding');
403
500
  previous = next;
404
501
  });
405
502
  }
@@ -519,16 +616,20 @@ export function text(scope, parent, before, dependencies, read, source) {
519
616
  let value = read();
520
617
  if (Array.isArray(value)) {
521
618
  validateContent(value);
522
- value = { [contentBrand]: true, definition: arrayContent, value };
619
+ value = new SlotPlacement(arrayContent, value);
523
620
  }
524
621
  if (isContent(value)) {
525
- node.data = '';
622
+ if (node.data !== '')
623
+ node.data = '';
526
624
  if (content?.definition === value.definition) {
527
625
  content.mounted.set(value.value);
528
626
  return;
529
627
  }
530
- content?.mounted.dispose();
628
+ const previous = content;
531
629
  content = undefined;
630
+ previous?.mounted.dispose();
631
+ if (scope.disposed)
632
+ return;
532
633
  if (start)
533
634
  clear(start, node);
534
635
  else {
@@ -536,10 +637,11 @@ export function text(scope, parent, before, dependencies, read, source) {
536
637
  scope.cleanups.push(() => content?.mounted.dispose());
537
638
  node.parentNode.insertBefore(start, node);
538
639
  }
539
- content = {
540
- definition: value.definition,
541
- mounted: value.definition.mount(node.parentNode, node, value.value, scope.report, scope.settlement),
542
- };
640
+ const mounted = value.definition.mount(node.parentNode, node, value.value, scope.report, scope.settlement);
641
+ if (scope.disposed)
642
+ mounted.dispose();
643
+ else
644
+ content = { definition: value.definition, mounted };
543
645
  return;
544
646
  }
545
647
  if (value != null && typeof value === 'object')
@@ -575,20 +677,28 @@ const arrayContent = {
575
677
  /** Runtime content arrays are positional; domain lists retain the explicit collection contract. */
576
678
  function mountArray(report, before, initial, settlement) {
577
679
  const cells = [];
680
+ let disposed = false;
578
681
  const disposeCell = (cell) => {
579
682
  cell.scope.dispose();
580
683
  remove(cell.start, cell.end);
581
684
  };
582
685
  const set = (input) => {
686
+ if (disposed)
687
+ return;
583
688
  const values = input;
584
689
  validateContent(values);
585
- while (cells.length > values.length)
690
+ while (cells.length > values.length) {
586
691
  disposeCell(cells.pop());
692
+ if (disposed)
693
+ return;
694
+ }
587
695
  for (let index = 0; index < values.length; index++) {
588
696
  const cell = cells[index];
589
697
  if (cell) {
590
698
  if (!Object.is(cell.scope.value, values[index]))
591
699
  cell.scope.set(values[index]);
700
+ if (disposed)
701
+ return;
592
702
  continue;
593
703
  }
594
704
  const scope = new Scope(values[index], unboundSend, report, settlement);
@@ -596,6 +706,10 @@ function mountArray(report, before, initial, settlement) {
596
706
  const range = markers(fragment, null);
597
707
  try {
598
708
  text(scope, fragment, range.end, () => [scope.value], () => scope.value);
709
+ if (disposed) {
710
+ scope.dispose();
711
+ return;
712
+ }
599
713
  before.parentNode.insertBefore(fragment, before);
600
714
  cells.push({ scope, ...range });
601
715
  }
@@ -606,6 +720,9 @@ function mountArray(report, before, initial, settlement) {
606
720
  }
607
721
  };
608
722
  const dispose = () => {
723
+ if (disposed)
724
+ return;
725
+ disposed = true;
609
726
  for (const cell of cells.splice(0))
610
727
  disposeCell(cell);
611
728
  };
@@ -652,29 +769,28 @@ export function event(scope, element, name, handler) {
652
769
  effects?.dispose();
653
770
  });
654
771
  }
655
- /** One handler identity owns its requests, regardless of the JSX attribute spelling. */
772
+ /** The event binding owns its requests; snapshot renders may replace its callback. */
656
773
  export function bindEvent(scope, element, name, dependencies, read) {
657
774
  let current;
658
775
  let owned;
659
776
  scope.cleanups.push(() => owned?.dispose());
660
777
  scope.watch(dependencies, () => {
661
778
  const next = read();
662
- if (next != null && typeof next !== 'function')
663
- throw new TypeError(`Event ${name} must be a synchronous handler.`);
664
779
  if (Object.is(current, next))
665
780
  return;
666
- owned?.dispose();
667
- owned = undefined;
668
781
  current = next;
669
- if (typeof next === 'function') {
670
- const handler = next;
782
+ if (!next) {
783
+ owned?.dispose();
784
+ owned = undefined;
785
+ }
786
+ else if (!owned) {
671
787
  owned = new Scope(next, scope.send, scope.report, scope.settlement);
672
- event(owned, element, name, handler);
788
+ event(owned, element, name, (event) => current?.(event));
673
789
  }
674
790
  });
675
791
  }
676
792
  /** Reconcile merged JSX attributes. Each host, event and control owns its cleanup. */
677
- export function bindAttributes(scope, element, dependencies, read) {
793
+ export function bindAttributes(scope, element, dependencies, read, sources) {
678
794
  const bindings = new Map();
679
795
  let previous = {};
680
796
  const isEvent = (name) => /^on[A-Z]/u.test(name);
@@ -689,8 +805,6 @@ export function bindAttributes(scope, element, dependencies, read) {
689
805
  for (const name of Object.keys(next)) {
690
806
  if (['key', 'ref', 'innerHTML', 'children'].includes(name))
691
807
  throw new Error(`Spread attribute ${name} is unsupported. Use collections, DOM hosts, or JSX children.`);
692
- if (isEvent(name) && next[name] != null && typeof next[name] !== 'function')
693
- throw new Error(`Spread event ${name} must be a synchronous handler.`);
694
808
  }
695
809
  for (const name of Object.keys(previous)) {
696
810
  if (Object.hasOwn(next, name))
@@ -701,8 +815,11 @@ export function bindAttributes(scope, element, dependencies, read) {
701
815
  attribute(element, name, undefined);
702
816
  }
703
817
  for (const [name, value] of Object.entries(next)) {
818
+ if (!Object.hasOwn(previous, name) || !Object.is(previous[name], value))
819
+ traceBinding(sources?.()?.[name], Object.hasOwn(previous, name) ? [previous[name]] : undefined, [value], 'binding');
704
820
  if (name !== 'use' && !isEvent(name) && !isControl(name)) {
705
- attribute(element, name, value);
821
+ if (!Object.hasOwn(previous, name) || !Object.is(previous[name], value))
822
+ attribute(element, name, value);
706
823
  continue;
707
824
  }
708
825
  let binding = bindings.get(name);
@@ -767,6 +884,8 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
767
884
  let previousOuter = [];
768
885
  let previousIdentities = [];
769
886
  const update = () => {
887
+ if (scope.disposed)
888
+ return;
770
889
  const target = end.parentNode;
771
890
  const next = read();
772
891
  const nextOuter = outer();
@@ -787,8 +906,11 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
787
906
  target instanceof Element &&
788
907
  target.firstChild === rows.get(previousIdentities[0])?.start &&
789
908
  target.lastChild === end) {
790
- for (const row of rows.values())
909
+ for (const row of rows.values()) {
791
910
  row.scope.dispose();
911
+ if (scope.disposed)
912
+ return;
913
+ }
792
914
  target.replaceChildren(end);
793
915
  rows.clear();
794
916
  previousList = next;
@@ -796,24 +918,21 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
796
918
  previousIdentities = [];
797
919
  return;
798
920
  }
799
- const identities = validateIdentities(items, (item, index) => {
800
- const key = collection ? collection.identity(item, index) : item;
801
- if (typeof key !== 'string' && typeof key !== 'number')
802
- throw new Error('Object lists need domain identity. Declare collection(identity) once; do not add JSX keys.');
803
- return key;
804
- });
921
+ const identities = validateIdentities(items, (item, index) => collection ? collection.identity(item, index) : item);
805
922
  const keep = new Set(identities);
806
923
  for (const [key, row] of rows) {
807
924
  if (keep.has(key))
808
925
  continue;
809
926
  row.scope.dispose();
927
+ if (scope.disposed)
928
+ return;
810
929
  remove(row.start, row.end);
811
930
  rows.delete(key);
812
931
  }
813
932
  for (let index = 0; index < items.length; index++) {
814
933
  const key = identities[index];
815
934
  const row = rows.get(key);
816
- const item = row ? shareData(row.scope.value[0], items[index]) : items[index];
935
+ const item = items[index];
817
936
  if (!row) {
818
937
  const fragment = buildFragment(target);
819
938
  const range = markers(fragment, null);
@@ -825,6 +944,10 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
825
944
  child.dispose();
826
945
  throw error;
827
946
  }
947
+ if (scope.disposed) {
948
+ child.dispose();
949
+ return;
950
+ }
828
951
  // A single element is its own stable range. Keep markers for dynamic/multiple roots.
829
952
  const root = range.start.nextSibling;
830
953
  const single = root instanceof Element && root.nextSibling === range.end;
@@ -843,6 +966,8 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
843
966
  row.scope.value[0] !== item ||
844
967
  (indexUsed && row.scope.value[1] !== index)) {
845
968
  row.scope.set([item, index]);
969
+ if (scope.disposed)
970
+ return;
846
971
  }
847
972
  }
848
973
  // Retained prefixes are already ordered. New tail rows were appended above and
@@ -892,16 +1017,6 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
892
1017
  });
893
1018
  update();
894
1019
  }
895
- export function invoke(scope, parent, before, dependencies, read, build) {
896
- const child = new Scope(read(), scope.send, scope.report, scope.settlement);
897
- scope.cleanups.push(() => child.dispose());
898
- build(child, parent, before);
899
- scope.watch(dependencies, () => {
900
- const next = read();
901
- if (!equal(child.value, next))
902
- child.set(next);
903
- });
904
- }
905
1020
  export function child(scope, parent, before, definition, dependencies, model, send) {
906
1021
  const child = new Scope(model(), send, scope.report, scope.settlement);
907
1022
  scope.cleanups.push(() => child.dispose());
@@ -965,7 +1080,7 @@ export function attach(scope, element, dependencies, read) {
965
1080
  let active;
966
1081
  scope.cleanups.push(() => {
967
1082
  generation++;
968
- active?.dispose();
1083
+ active?.dispose(scope.settlement.exit);
969
1084
  active = undefined;
970
1085
  });
971
1086
  scope.watch(dependencies, () => {
@@ -981,12 +1096,14 @@ export function attach(scope, element, dependencies, read) {
981
1096
  if (!scope.disposed && token === generation) {
982
1097
  const finished = scope.settlement.begin();
983
1098
  try {
984
- const acquired = startMount(element, mount, scope.report);
985
- acquired.closed.then(finished, finished);
1099
+ const acquired = prepareMount(element, mount, scope.report, scope.settlement.runtime);
1100
+ Effect.runFork(acquired.closed).addObserver(finished);
986
1101
  if (scope.disposed || token !== generation)
987
1102
  acquired.dispose();
988
- else
1103
+ else {
989
1104
  active = acquired;
1105
+ acquired.start();
1106
+ }
990
1107
  }
991
1108
  catch (error) {
992
1109
  finished();
@@ -1106,4 +1223,3 @@ export function template(build, depth = 0) {
1106
1223
  export function literal(parent, before, value) {
1107
1224
  parent.insertBefore((parent.ownerDocument ?? document).createTextNode(value), before);
1108
1225
  }
1109
- export { intrinsic } from './intrinsic.js';
@@ -1,19 +1,22 @@
1
- import { Effect } from 'effect';
1
+ import * as Effect from 'effect/Effect';
2
+ import type * as Scope from 'effect/Scope';
2
3
  import { type ReportError } from './errors.js';
3
4
  import type { Settlement } from './settlement.js';
4
5
  import { type UiRuntime } from './runtime.js';
5
6
  declare const tag: unique symbol;
6
7
  export interface EffectEventRequest {
7
8
  readonly [tag]: true;
8
- readonly policy: 'drop' | 'replace';
9
- readonly effect: Effect.Effect<unknown, unknown>;
9
+ readonly policy: 'drop' | 'replace' | 'parallel';
10
+ readonly effect: Effect.Effect<unknown, unknown, Scope.Scope>;
10
11
  }
11
12
  /** Capture/prevent native events on every call; the policy gates only Effect execution. */
12
- export declare function effectEvent<EventType extends Event, E>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E>): (event: EventType) => EffectEventRequest;
13
- export declare function effectEvent<EventType extends Event, E, R>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, R>, runtime: UiRuntime<R>): (event: EventType) => EffectEventRequest;
14
- /** Internal listener owner, allocated only when an event returns a value needing inspection. */
13
+ export declare function effectEvent<EventType extends Event, E>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, Scope.Scope>): (event: EventType) => EffectEventRequest;
14
+ export declare function effectEvent<EventType extends Event, E, R>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, R | Scope.Scope>, runtime: UiRuntime<R>): (event: EventType) => EffectEventRequest;
15
+ /** Capture an operation's services in Effect setup; its event listener supplies the work scope. */
16
+ export declare const makeEffectHandler: <Args extends readonly unknown[], A, E, R = never>(work: (...args: Args) => Effect.Effect<A, E, R | Scope.Scope>) => Effect.Effect<(...args: Args) => Effect.Effect<A, E, Scope.Scope>, never, R>;
17
+ /** Internal listener owner, allocated only when an event returns an owned request. */
15
18
  export declare function eventEffects(report: ReportError, settlement?: Settlement): {
16
- accept(value: unknown): void;
19
+ accept(value: EffectEventRequest | Effect.Effect<unknown, unknown, Scope.Scope>): void;
17
20
  dispose(): void;
18
21
  };
19
22
  export {};