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/dom.js CHANGED
@@ -1,22 +1,53 @@
1
+ import { compiledSlots } from './slotIdentity.js';
2
+ import attributeData from './dom-attributes.json' with { type: 'json' };
3
+ import { validateIdentities } from './collection.js';
1
4
  import { runAll, reportError, reportSafely } from './errors.js';
2
5
  import { eventEffects } from './effectEvent.js';
3
6
  import { traceBinding } from './diagnostics.js';
4
- import { shareValue } from './share.js';
7
+ import { shareData } from './sharing.js';
8
+ import { jsxComponent } from './jsx.js';
9
+ import { Settlement } from './settlement.js';
5
10
  import { startMount } from './mount.js';
6
11
  const equal = (a, b) => a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
12
+ // A model publication reconciles DOM first and controlled dependent properties second.
13
+ // Nested child scopes join the same synchronous commit; there is no reactive graph.
14
+ let commitDepth = 0;
15
+ const controlCommits = new Set();
16
+ function commitDom(work) {
17
+ commitDepth++;
18
+ try {
19
+ return work();
20
+ }
21
+ finally {
22
+ if (--commitDepth === 0) {
23
+ for (const apply of controlCommits) {
24
+ controlCommits.delete(apply);
25
+ apply();
26
+ }
27
+ }
28
+ }
29
+ }
30
+ function afterDom(apply) {
31
+ if (commitDepth)
32
+ controlCommits.add(apply);
33
+ else
34
+ apply();
35
+ }
7
36
  /** Compiler implementation. No implicit tracking, proxies, or per-binding subscriptions. */
8
37
  export class Scope {
9
38
  value;
10
39
  send;
11
40
  report;
41
+ settlement;
12
42
  jobs = [];
13
43
  cleanups = [];
14
44
  disposed = false;
15
45
  revision = 0;
16
- constructor(value, send, report = reportError) {
46
+ constructor(value, send, report = reportError, settlement = new Settlement()) {
17
47
  this.value = value;
18
48
  this.send = send;
19
49
  this.report = report;
50
+ this.settlement = settlement;
20
51
  }
21
52
  derive(dependencies, compute, source) {
22
53
  let previous;
@@ -27,7 +58,7 @@ export class Scope {
27
58
  return value;
28
59
  const next = dependencies();
29
60
  if (!previous || !equal(previous, next)) {
30
- value = previous ? shareValue(value, compute()) : compute();
61
+ value = previous ? shareData(value, compute()) : compute();
31
62
  traceBinding(source, previous, next, 'derive');
32
63
  previous = next;
33
64
  }
@@ -54,18 +85,20 @@ export class Scope {
54
85
  set(value) {
55
86
  if (this.disposed)
56
87
  return;
57
- this.value = value;
58
- this.revision++;
59
- for (const job of this.jobs) {
60
- if (this.disposed)
61
- break;
62
- try {
63
- job();
64
- }
65
- catch (error) {
66
- reportSafely(this.report, error);
88
+ commitDom(() => {
89
+ this.value = value;
90
+ this.revision++;
91
+ for (const job of this.jobs) {
92
+ if (this.disposed)
93
+ break;
94
+ try {
95
+ job();
96
+ }
97
+ catch (error) {
98
+ reportSafely(this.report, error);
99
+ }
67
100
  }
68
- }
101
+ });
69
102
  }
70
103
  dispose() {
71
104
  if (this.disposed)
@@ -75,6 +108,14 @@ export class Scope {
75
108
  runAll(this.cleanups.splice(0).reverse(), this.report);
76
109
  }
77
110
  }
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');
114
+ }, { [jsxComponent]: true });
115
+ /** Untyped callers get an actionable failure instead of silently losing child messages. */
116
+ export function unboundSend(_message) {
117
+ throw new Error('This view needs a dispatcher. Mount it through ViewBinding with model and send.');
118
+ }
78
119
  const contentBrand = Symbol('compiled content');
79
120
  /** Compiler marker: declare inside view(), or directly in a compiled component prop. */
80
121
  export function slot(_render) {
@@ -108,10 +149,10 @@ export function compiledSlot(owner, build) {
108
149
  placements.clear();
109
150
  });
110
151
  const definition = {
111
- mount(parent, before, value) {
152
+ mount(parent, before, value, report, settlement) {
112
153
  if (owner.disposed)
113
154
  throw new Error('Cannot mount content after its declaring view disposed');
114
- const scope = new Scope(value, owner.send, owner.report);
155
+ const scope = new Scope(value, owner.send, report, settlement);
115
156
  const fragment = buildFragment(parent);
116
157
  const range = markers(fragment, null);
117
158
  scope.cleanups.push(() => remove(range.start, range.end));
@@ -136,10 +177,12 @@ export function compiledSlot(owner, build) {
136
177
  };
137
178
  },
138
179
  };
139
- return Object.assign((value) => new SlotPlacement(definition, value), {
180
+ const slot = Object.assign((value) => new SlotPlacement(definition, value), {
140
181
  [contentBrand]: true,
141
182
  definition,
142
183
  });
184
+ compiledSlots.add(slot);
185
+ return slot;
143
186
  }
144
187
  /** This declaration is a compiler marker, never a component setup callback. */
145
188
  export function view(_render) {
@@ -148,8 +191,14 @@ export function view(_render) {
148
191
  export function compiled(build) {
149
192
  return Object.assign(() => {
150
193
  throw new Error('Mount compiled views with mountView or inside another compiled view');
151
- }, { build });
194
+ }, { build, [jsxComponent]: true });
152
195
  }
196
+ /** Owned content in an element target, defaulting to the document body. */
197
+ export const Portal = /* @__PURE__ */ compiled((scope) => {
198
+ portal(scope, (child, parent, before) => {
199
+ text(child, parent, before, () => [child.value.children], () => child.value.children);
200
+ }, () => scope.value.mount);
201
+ });
153
202
  function markers(parent, before) {
154
203
  const start = document.createComment('');
155
204
  const end = document.createComment('');
@@ -210,25 +259,34 @@ function buildFragment(parent) {
210
259
  return fragment;
211
260
  }
212
261
  export function mountView(parent, definition, source, options = {}) {
213
- const { start, end } = markers(parent, null);
214
- const scope = new Scope(source.model(), source.send, options.onError);
215
- let unsubscribe = () => { };
216
- try {
217
- const fragment = buildFragment(parent);
218
- definition.build(scope, fragment, null);
219
- parent.insertBefore(fragment, end);
220
- unsubscribe = source.subscribe((model) => scope.set(model));
221
- }
222
- catch (error) {
223
- scope.dispose();
224
- remove(start, end);
225
- throw error;
226
- }
227
- return () => {
228
- if (scope.disposed)
229
- return;
230
- runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
231
- };
262
+ return commitDom(() => {
263
+ const { start, end } = markers(parent, null);
264
+ const scope = new Scope(source.model(), source.send, options.onError);
265
+ let unsubscribe = () => { };
266
+ try {
267
+ const fragment = buildFragment(parent);
268
+ definition.build(scope, fragment, null);
269
+ parent.insertBefore(fragment, end);
270
+ unsubscribe = source.subscribe((model) => scope.set(model));
271
+ }
272
+ catch (error) {
273
+ scope.dispose();
274
+ remove(start, end);
275
+ throw error;
276
+ }
277
+ const dispose = () => {
278
+ if (scope.disposed)
279
+ return;
280
+ runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
281
+ };
282
+ return Object.assign(dispose, {
283
+ dispose,
284
+ close: () => {
285
+ dispose();
286
+ return scope.settlement.wait();
287
+ },
288
+ });
289
+ });
232
290
  }
233
291
  export function element(parent, before, tag) {
234
292
  const doc = parent.ownerDocument ?? document;
@@ -239,6 +297,7 @@ export function element(parent, before, tag) {
239
297
  return node;
240
298
  }
241
299
  const classTokens = new WeakMap();
300
+ let booleanAttributes;
242
301
  const styleProperties = new WeakMap();
243
302
  function scalar(value) {
244
303
  if (value == null)
@@ -251,7 +310,11 @@ function scalar(value) {
251
310
  throw new Error('DOM values must be scalar. Render objects as compiled child views or domain collections.');
252
311
  }
253
312
  export function attribute(element, name, value) {
254
- const key = name === 'className' ? 'class' : name === 'tabIndex' ? 'tabindex' : name;
313
+ const aliases = attributeData.aliases;
314
+ const key = aliases[name] ?? name;
315
+ const booleanValues = attributeData.booleanValues;
316
+ if (typeof value === 'boolean')
317
+ value = booleanValues[key]?.[String(value)] ?? value;
255
318
  if (name === 'classList') {
256
319
  const tokens = (value ?? {});
257
320
  const next = new Set();
@@ -287,31 +350,19 @@ export function attribute(element, name, value) {
287
350
  if (element.value !== next)
288
351
  element.value = next;
289
352
  }
290
- else if ([
291
- 'checked',
292
- 'selected',
293
- 'disabled',
294
- 'multiple',
295
- 'hidden',
296
- 'autofocus',
297
- 'controls',
298
- 'autoplay',
299
- 'loop',
300
- 'muted',
301
- 'playsinline',
302
- 'readonly',
303
- 'required',
304
- 'open',
305
- 'inert',
306
- 'download',
307
- ].includes(name) &&
308
- typeof value === 'boolean') {
309
- element.toggleAttribute(name, Boolean(value));
310
- if (name in element)
311
- Reflect.set(element, name, Boolean(value));
353
+ else if ((typeof value === 'boolean' || value == null) &&
354
+ (booleanAttributes ??= new Set(attributeData.boolean)).has(key)) {
355
+ element.toggleAttribute(key, Boolean(value));
356
+ const properties = attributeData.properties;
357
+ const property = properties[key] ?? key;
358
+ if (typeof Reflect.get(element, property) === 'boolean')
359
+ Reflect.set(element, property, Boolean(value));
312
360
  }
313
361
  else if (value == null ||
314
- (value === false && !name.startsWith('aria-') && !name.startsWith('data-'))) {
362
+ (value === false &&
363
+ !attributeData.booleanish.includes(key) &&
364
+ !name.startsWith('aria-') &&
365
+ !name.startsWith('data-'))) {
315
366
  element.removeAttribute(key);
316
367
  }
317
368
  else {
@@ -322,8 +373,12 @@ export function attribute(element, name, value) {
322
373
  if (key === 'class')
323
374
  for (const token of classTokens.get(element) ?? [])
324
375
  element.classList.add(token);
325
- if (name === 'style' && (value == null || typeof value !== 'object'))
326
- styleProperties.delete(element);
376
+ if (name === 'style' && (value == null || typeof value !== 'object')) {
377
+ if (value == null)
378
+ styleProperties.delete(element);
379
+ else if ('style' in element)
380
+ styleProperties.set(element, new Set(Array.from(element.style)));
381
+ }
327
382
  }
328
383
  /** Compiler output for attributes whose unchanged value needs no DOM refresh. */
329
384
  export function bindAttribute(scope, element, name, dependencies, read, source) {
@@ -348,13 +403,124 @@ export function bindAttribute(scope, element, name, dependencies, read, source)
348
403
  previous = next;
349
404
  });
350
405
  }
406
+ const controlRestorations = new WeakMap();
407
+ /** Handled edits can change a control even when dispatch publishes no new model. */
408
+ export function bindControl(scope, element, name, dependencies, read, source) {
409
+ let composing = false;
410
+ let deferred = false;
411
+ let pending;
412
+ const select = element.tagName === 'SELECT' && name === 'value';
413
+ const write = () => {
414
+ if (scope.disposed)
415
+ return;
416
+ try {
417
+ if (composing)
418
+ deferred = true;
419
+ else
420
+ attribute(element, name, read());
421
+ }
422
+ catch (error) {
423
+ reportSafely(scope.report, error);
424
+ }
425
+ };
426
+ const apply = () => {
427
+ if (select)
428
+ afterDom(write);
429
+ else
430
+ write();
431
+ };
432
+ let initialized = false;
433
+ let published;
434
+ scope.watch(dependencies, () => {
435
+ const next = read();
436
+ // A coarse dependency can change while this field's published value stays equal.
437
+ // Preserve unfinished blur drafts; handled edits still restore through apply below.
438
+ if (!initialized || !Object.is(published, next)) {
439
+ initialized = true;
440
+ published = next;
441
+ apply();
442
+ }
443
+ }, source);
444
+ if (select) {
445
+ // The selected value may stay equal while its options change on this publication.
446
+ scope.jobs.push(apply);
447
+ // Options may also belong to a separately updating component or a DOM host.
448
+ // Observe only option-affecting mutations; setting selectedness does not mutate attributes.
449
+ const observer = new MutationObserver(apply);
450
+ observer.observe(element, {
451
+ childList: true,
452
+ subtree: true,
453
+ characterData: true,
454
+ attributes: true,
455
+ attributeFilter: ['value', 'selected', 'disabled'],
456
+ });
457
+ scope.cleanups.push(() => {
458
+ observer.disconnect();
459
+ controlCommits.delete(write);
460
+ });
461
+ }
462
+ const restore = () => {
463
+ if (composing) {
464
+ deferred = true;
465
+ return;
466
+ }
467
+ if (pending !== undefined || scope.disposed)
468
+ return;
469
+ // Native dispatch may run microtasks between listeners. A task waits for all
470
+ // handlers (including ancestors) before restoring the latest model value.
471
+ pending = setTimeout(() => {
472
+ pending = undefined;
473
+ if (!scope.disposed) {
474
+ try {
475
+ apply();
476
+ }
477
+ catch (error) {
478
+ reportSafely(scope.report, error);
479
+ }
480
+ }
481
+ }, 0);
482
+ };
483
+ const start = () => {
484
+ composing = true;
485
+ };
486
+ const end = () => {
487
+ composing = false;
488
+ if (deferred) {
489
+ deferred = false;
490
+ restore();
491
+ }
492
+ };
493
+ const handled = (type) => {
494
+ // Clicking a text control must not commit its unfinished blur draft.
495
+ if (type !== 'click' || name === 'checked')
496
+ restore();
497
+ };
498
+ let restorations = controlRestorations.get(element);
499
+ if (!restorations)
500
+ controlRestorations.set(element, (restorations = new Set()));
501
+ restorations.add(handled);
502
+ element.addEventListener('compositionstart', start);
503
+ element.addEventListener('compositionend', end);
504
+ scope.cleanups.push(() => {
505
+ clearTimeout(pending);
506
+ restorations.delete(handled);
507
+ if (!restorations.size)
508
+ controlRestorations.delete(element);
509
+ element.removeEventListener('compositionstart', start);
510
+ element.removeEventListener('compositionend', end);
511
+ });
512
+ }
351
513
  export function text(scope, parent, before, dependencies, read, source) {
352
514
  const node = document.createTextNode('');
353
515
  parent.insertBefore(node, before);
354
516
  let start;
355
517
  let content;
356
518
  scope.watch(dependencies, () => {
357
- const value = read();
519
+ let value = read();
520
+ if (Array.isArray(value)) {
521
+ validateContent(value);
522
+ value = { [contentBrand]: true, definition: arrayContent, value };
523
+ }
358
524
  if (isContent(value)) {
359
525
  node.data = '';
360
526
  if (content?.definition === value.definition) {
@@ -372,38 +538,112 @@ export function text(scope, parent, before, dependencies, read, source) {
372
538
  }
373
539
  content = {
374
540
  definition: value.definition,
375
- mounted: value.definition.mount(node.parentNode, node, value.value),
541
+ mounted: value.definition.mount(node.parentNode, node, value.value, scope.report, scope.settlement),
376
542
  };
377
543
  return;
378
544
  }
545
+ if (value != null && typeof value === 'object')
546
+ throw new Error('Unsupported JSX content. Use compiled views or slots for markup, collections for entities, and an owned DOM host for native nodes.');
547
+ const next = value == null || typeof value === 'boolean' ? '' : scalar(value);
379
548
  if (content) {
380
549
  content.mounted.dispose();
381
550
  content = undefined;
382
551
  clear(start, node);
383
552
  }
384
- if (value != null && typeof value === 'object')
385
- throw new Error('Object rendered as text. Use collection(...).from(items).map(...) for entity lists.');
386
- const next = value == null || typeof value === 'boolean' ? '' : scalar(value);
387
553
  if (node.data !== next)
388
554
  node.data = next;
389
555
  }, source);
390
556
  }
557
+ function validateContent(value, active = new Set()) {
558
+ if (Array.isArray(value)) {
559
+ if (active.has(value))
560
+ throw new TypeError('JSX content arrays cannot contain cycles.');
561
+ active.add(value);
562
+ for (const child of value)
563
+ validateContent(child, active);
564
+ active.delete(value);
565
+ }
566
+ else if (!isContent(value) &&
567
+ value != null &&
568
+ !['string', 'number', 'boolean'].includes(typeof value)) {
569
+ throw new TypeError('Unsupported JSX content. Use an owned DOM host for native nodes and compiled views for objects.');
570
+ }
571
+ }
572
+ const arrayContent = {
573
+ mount: (_parent, before, value, report, settlement) => mountArray(report, before, value, settlement),
574
+ };
575
+ /** Runtime content arrays are positional; domain lists retain the explicit collection contract. */
576
+ function mountArray(report, before, initial, settlement) {
577
+ const cells = [];
578
+ const disposeCell = (cell) => {
579
+ cell.scope.dispose();
580
+ remove(cell.start, cell.end);
581
+ };
582
+ const set = (input) => {
583
+ const values = input;
584
+ validateContent(values);
585
+ while (cells.length > values.length)
586
+ disposeCell(cells.pop());
587
+ for (let index = 0; index < values.length; index++) {
588
+ const cell = cells[index];
589
+ if (cell) {
590
+ if (!Object.is(cell.scope.value, values[index]))
591
+ cell.scope.set(values[index]);
592
+ continue;
593
+ }
594
+ const scope = new Scope(values[index], unboundSend, report, settlement);
595
+ const fragment = buildFragment(before.parentNode);
596
+ const range = markers(fragment, null);
597
+ try {
598
+ text(scope, fragment, range.end, () => [scope.value], () => scope.value);
599
+ before.parentNode.insertBefore(fragment, before);
600
+ cells.push({ scope, ...range });
601
+ }
602
+ catch (error) {
603
+ scope.dispose();
604
+ throw error;
605
+ }
606
+ }
607
+ };
608
+ const dispose = () => {
609
+ for (const cell of cells.splice(0))
610
+ disposeCell(cell);
611
+ };
612
+ try {
613
+ set(initial);
614
+ }
615
+ catch (error) {
616
+ dispose();
617
+ throw error;
618
+ }
619
+ return { set, dispose };
620
+ }
391
621
  export function event(scope, element, name, handler) {
392
622
  let effects;
393
- const capture = name.endsWith('Capture');
394
- const type = name.slice(2, capture ? -7 : undefined).toLowerCase();
623
+ const capture = name.endsWith('Capture') && name !== 'onGotPointerCapture' && name !== 'onLostPointerCapture';
624
+ const nativeName = name.slice(2, capture ? -7 : undefined);
625
+ const type = nativeName === 'Begin' || nativeName === 'End' || nativeName === 'Repeat'
626
+ ? `${nativeName.toLowerCase()}Event`
627
+ : nativeName.toLowerCase();
395
628
  const listener = (event) => {
396
629
  if (!scope.disposed) {
397
630
  try {
398
631
  const result = handler(event);
399
632
  if (result !== null && typeof result === 'object' && !scope.disposed) {
400
- effects ??= eventEffects(scope.report);
633
+ effects ??= eventEffects(scope.report, scope.settlement);
401
634
  effects.accept(result);
402
635
  }
403
636
  }
404
637
  catch (error) {
405
638
  reportSafely(scope.report, error);
406
639
  }
640
+ finally {
641
+ // Restore only after an application handler commits or rejects an edit.
642
+ // The target also covers handlers delegated to an ancestor.
643
+ if (event.target && ['input', 'change', 'blur', 'focusout', 'click'].includes(type))
644
+ for (const restore of controlRestorations.get(event.target) ?? [])
645
+ restore(type);
646
+ }
407
647
  }
408
648
  };
409
649
  element.addEventListener(type, listener, capture);
@@ -412,6 +652,84 @@ export function event(scope, element, name, handler) {
412
652
  effects?.dispose();
413
653
  });
414
654
  }
655
+ /** One handler identity owns its requests, regardless of the JSX attribute spelling. */
656
+ export function bindEvent(scope, element, name, dependencies, read) {
657
+ let current;
658
+ let owned;
659
+ scope.cleanups.push(() => owned?.dispose());
660
+ scope.watch(dependencies, () => {
661
+ const next = read();
662
+ if (next != null && typeof next !== 'function')
663
+ throw new TypeError(`Event ${name} must be a synchronous handler.`);
664
+ if (Object.is(current, next))
665
+ return;
666
+ owned?.dispose();
667
+ owned = undefined;
668
+ current = next;
669
+ if (typeof next === 'function') {
670
+ const handler = next;
671
+ owned = new Scope(next, scope.send, scope.report, scope.settlement);
672
+ event(owned, element, name, handler);
673
+ }
674
+ });
675
+ }
676
+ /** Reconcile merged JSX attributes. Each host, event and control owns its cleanup. */
677
+ export function bindAttributes(scope, element, dependencies, read) {
678
+ const bindings = new Map();
679
+ let previous = {};
680
+ const isEvent = (name) => /^on[A-Z]/u.test(name);
681
+ const isControl = (name) => (name === 'value' && ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)) ||
682
+ (name === 'checked' && element.tagName === 'INPUT');
683
+ scope.cleanups.push(() => {
684
+ runAll([...bindings.values()].reverse().map((binding) => () => binding.dispose()), scope.report);
685
+ bindings.clear();
686
+ });
687
+ scope.watch(dependencies, () => {
688
+ const next = read();
689
+ for (const name of Object.keys(next)) {
690
+ if (['key', 'ref', 'innerHTML', 'children'].includes(name))
691
+ 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
+ }
695
+ for (const name of Object.keys(previous)) {
696
+ if (Object.hasOwn(next, name))
697
+ continue;
698
+ bindings.get(name)?.dispose();
699
+ bindings.delete(name);
700
+ if (name !== 'use' && !isEvent(name))
701
+ attribute(element, name, undefined);
702
+ }
703
+ for (const [name, value] of Object.entries(next)) {
704
+ if (name !== 'use' && !isEvent(name) && !isControl(name)) {
705
+ attribute(element, name, value);
706
+ continue;
707
+ }
708
+ let binding = bindings.get(name);
709
+ if (binding && Object.is(binding.value, value))
710
+ continue;
711
+ if (binding) {
712
+ binding.set(value);
713
+ continue;
714
+ }
715
+ const owned = new Scope(value, scope.send, scope.report, scope.settlement);
716
+ bindings.set(name, owned);
717
+ if (name === 'use')
718
+ attach(owned, element, () => [owned.value], () => owned.value);
719
+ else if (isControl(name))
720
+ bindControl(owned, element, name, () => [owned.value], () => owned.value);
721
+ else
722
+ bindEvent(owned, element, name, () => [owned.value], () => owned.value);
723
+ }
724
+ previous = next;
725
+ });
726
+ if (element.tagName === 'SELECT')
727
+ scope.jobs.push(() => {
728
+ const binding = bindings.get('value');
729
+ if (binding)
730
+ binding.set(binding.value);
731
+ });
732
+ }
415
733
  export function branch(scope, parent, before, choose, yes, no) {
416
734
  const { start, end } = markers(parent, before);
417
735
  let child;
@@ -421,7 +739,7 @@ export function branch(scope, parent, before, choose, yes, no) {
421
739
  if (active !== next) {
422
740
  child?.dispose();
423
741
  clear(start, end);
424
- child = new Scope(scope.value, scope.send, scope.report);
742
+ child = new Scope(scope.value, scope.send, scope.report, scope.settlement);
425
743
  const fragment = buildFragment(end.parentNode);
426
744
  try {
427
745
  (next ? yes : no)(child, fragment, null);
@@ -478,15 +796,13 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
478
796
  previousIdentities = [];
479
797
  return;
480
798
  }
481
- const identities = items.map((item, index) => {
799
+ const identities = validateIdentities(items, (item, index) => {
482
800
  const key = collection ? collection.identity(item, index) : item;
483
801
  if (typeof key !== 'string' && typeof key !== 'number')
484
802
  throw new Error('Object lists need domain identity. Declare collection(identity) once; do not add JSX keys.');
485
803
  return key;
486
804
  });
487
805
  const keep = new Set(identities);
488
- if (keep.size !== identities.length)
489
- throw new Error('Duplicate collection identity. Identity must be unique within the rendered collection.');
490
806
  for (const [key, row] of rows) {
491
807
  if (keep.has(key))
492
808
  continue;
@@ -497,11 +813,11 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
497
813
  for (let index = 0; index < items.length; index++) {
498
814
  const key = identities[index];
499
815
  const row = rows.get(key);
500
- const item = row ? shareValue(row.scope.value[0], items[index]) : items[index];
816
+ const item = row ? shareData(row.scope.value[0], items[index]) : items[index];
501
817
  if (!row) {
502
818
  const fragment = buildFragment(target);
503
819
  const range = markers(fragment, null);
504
- const child = new Scope([item, index], scope.send, scope.report);
820
+ const child = new Scope([item, index], scope.send, scope.report, scope.settlement);
505
821
  try {
506
822
  build(child, fragment, range.end);
507
823
  }
@@ -577,7 +893,7 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
577
893
  update();
578
894
  }
579
895
  export function invoke(scope, parent, before, dependencies, read, build) {
580
- const child = new Scope(read(), scope.send, scope.report);
896
+ const child = new Scope(read(), scope.send, scope.report, scope.settlement);
581
897
  scope.cleanups.push(() => child.dispose());
582
898
  build(child, parent, before);
583
899
  scope.watch(dependencies, () => {
@@ -587,7 +903,7 @@ export function invoke(scope, parent, before, dependencies, read, build) {
587
903
  });
588
904
  }
589
905
  export function child(scope, parent, before, definition, dependencies, model, send) {
590
- const child = new Scope(model(), send, scope.report);
906
+ const child = new Scope(model(), send, scope.report, scope.settlement);
591
907
  scope.cleanups.push(() => child.dispose());
592
908
  definition.build(child, parent, before);
593
909
  scope.watch(dependencies, () => {
@@ -596,6 +912,54 @@ export function child(scope, parent, before, definition, dependencies, model, se
596
912
  child.set(next);
597
913
  });
598
914
  }
915
+ /** A late-bound compiled definition owns one replaceable DOM region. */
916
+ export function viewRegion(scope, parent, before, options = {}) {
917
+ const { start, end } = markers(parent, before);
918
+ let active;
919
+ let definition;
920
+ if (!options.manual)
921
+ scope.jobs.push(() => active?.set(scope.value));
922
+ scope.cleanups.push(() => {
923
+ active?.dispose();
924
+ remove(start, end);
925
+ });
926
+ return (next) => commitDom(() => {
927
+ if (scope.disposed)
928
+ return;
929
+ if (definition === next) {
930
+ active?.set(scope.value);
931
+ return;
932
+ }
933
+ const previous = active;
934
+ active = undefined;
935
+ definition = undefined;
936
+ previous?.dispose();
937
+ if (scope.disposed)
938
+ return;
939
+ clear(start, end);
940
+ if (!next)
941
+ return;
942
+ const child = new Scope(scope.value, scope.send, options.report ?? scope.report, scope.settlement);
943
+ const fragment = buildFragment(end.parentNode);
944
+ active = child;
945
+ definition = next;
946
+ try {
947
+ next.build(child, fragment, null);
948
+ if (scope.disposed || active !== child)
949
+ child.dispose();
950
+ else
951
+ end.parentNode.insertBefore(fragment, end);
952
+ }
953
+ catch (error) {
954
+ child.dispose();
955
+ if (active === child) {
956
+ active = undefined;
957
+ definition = undefined;
958
+ }
959
+ throw error;
960
+ }
961
+ });
962
+ }
599
963
  export function attach(scope, element, dependencies, read) {
600
964
  let generation = 0;
601
965
  let active;
@@ -615,27 +979,66 @@ export function attach(scope, element, dependencies, read) {
615
979
  return;
616
980
  queueMicrotask(() => {
617
981
  if (!scope.disposed && token === generation) {
982
+ const finished = scope.settlement.begin();
618
983
  try {
619
- active = startMount(element, mount, scope.report);
984
+ const acquired = startMount(element, mount, scope.report);
985
+ acquired.closed.then(finished, finished);
986
+ if (scope.disposed || token !== generation)
987
+ acquired.dispose();
988
+ else
989
+ active = acquired;
620
990
  }
621
991
  catch (error) {
992
+ finished();
622
993
  reportSafely(scope.report, error);
623
994
  }
624
995
  }
625
996
  });
626
997
  });
627
998
  }
628
- export function portal(scope, build) {
629
- const host = document.createElement('div');
630
- host.style.display = 'contents';
631
- document.body.appendChild(host);
632
- const child = new Scope(scope.value, scope.send, scope.report);
999
+ export function portal(scope, build, mount = () => undefined) {
1000
+ let host;
1001
+ let child;
633
1002
  scope.cleanups.push(() => {
634
- child.dispose();
635
- host.remove();
1003
+ child?.dispose();
1004
+ host?.remove();
636
1005
  });
637
- build(child, host, null);
638
- scope.jobs.push(() => child.set(scope.value));
1006
+ const update = () => {
1007
+ const target = mount() ?? document.body;
1008
+ const svg = svgChildren(target);
1009
+ if (!host || (host.namespaceURI === svgNamespace) !== svg) {
1010
+ child?.dispose();
1011
+ host?.remove();
1012
+ if (scope.disposed)
1013
+ return;
1014
+ host = svg
1015
+ ? target.ownerDocument.createElementNS(svgNamespace, 'g')
1016
+ : target.ownerDocument.createElement('div');
1017
+ host.style.display = 'contents';
1018
+ child = new Scope(scope.value, scope.send, scope.report, scope.settlement);
1019
+ target.appendChild(host);
1020
+ build(child, host, null);
1021
+ return;
1022
+ }
1023
+ if (host.parentNode !== target) {
1024
+ const focused = host.ownerDocument.activeElement;
1025
+ const move = Reflect.get(target, 'moveBefore');
1026
+ if (typeof move === 'function' &&
1027
+ target.isConnected &&
1028
+ host.isConnected &&
1029
+ target.ownerDocument === host.ownerDocument)
1030
+ move.call(target, host, null);
1031
+ else
1032
+ target.appendChild(host);
1033
+ if (focused instanceof HTMLElement &&
1034
+ focused.isConnected &&
1035
+ focused.ownerDocument.activeElement !== focused)
1036
+ focused.focus({ preventScroll: true });
1037
+ }
1038
+ child.set(scope.value);
1039
+ };
1040
+ scope.jobs.push(update);
1041
+ update();
639
1042
  }
640
1043
  // HTML parsing supplies the initial nodes. Unusual placements, such as mounting
641
1044
  // an HTML-named view inside SVG, still follow the same namespace rules as element().
@@ -703,3 +1106,4 @@ export function template(build, depth = 0) {
703
1106
  export function literal(parent, before, value) {
704
1107
  parent.insertBefore((parent.ownerDocument ?? document).createTextNode(value), before);
705
1108
  }
1109
+ export { intrinsic } from './intrinsic.js';