effectweb 0.2.2 → 0.3.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 (65) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +94 -2
  3. package/dist/AsyncContent.d.ts +2 -2
  4. package/dist/AsyncContent.js +36 -24
  5. package/dist/actions.d.ts +4 -4
  6. package/dist/actions.js +1 -1
  7. package/dist/cache-internals.d.ts +14 -0
  8. package/dist/cache-internals.js +11 -0
  9. package/dist/cache.d.ts +17 -15
  10. package/dist/cache.js +98 -27
  11. package/dist/collection.d.ts +10 -10
  12. package/dist/collection.js +32 -6
  13. package/dist/component.d.ts +8 -7
  14. package/dist/diagnostics.d.ts +25 -0
  15. package/dist/diagnostics.js +167 -1
  16. package/dist/dom-attributes.json +71 -0
  17. package/dist/dom.d.ts +36 -13
  18. package/dist/dom.js +547 -84
  19. package/dist/form.d.ts +61 -0
  20. package/dist/form.js +98 -0
  21. package/dist/html-attributes.d.ts +602 -0
  22. package/dist/html-attributes.js +1 -0
  23. package/dist/http.d.ts +5 -0
  24. package/dist/http.js +7 -0
  25. package/dist/index.d.ts +12 -10
  26. package/dist/index.js +7 -5
  27. package/dist/jsx.d.ts +165 -33
  28. package/dist/jsx.js +2 -1
  29. package/dist/lazy.d.ts +23 -0
  30. package/dist/lazy.js +58 -0
  31. package/dist/load.d.ts +3 -4
  32. package/dist/mount.d.ts +8 -11
  33. package/dist/mount.js +1 -3
  34. package/dist/owner.d.ts +11 -9
  35. package/dist/owner.js +27 -38
  36. package/dist/pages.d.ts +23 -23
  37. package/dist/pages.js +15 -10
  38. package/dist/program.d.ts +19 -10
  39. package/dist/program.js +172 -66
  40. package/dist/query-internals.d.ts +11 -0
  41. package/dist/query-internals.js +10 -0
  42. package/dist/query.d.ts +24 -20
  43. package/dist/query.js +60 -7
  44. package/dist/resource.d.ts +4 -10
  45. package/dist/resource.js +14 -5
  46. package/dist/runtime.d.ts +1 -2
  47. package/dist/runtime.js +3 -3
  48. package/dist/session.d.ts +12 -18
  49. package/dist/session.js +15 -15
  50. package/dist/share.d.ts +5 -4
  51. package/dist/share.js +5 -44
  52. package/dist/sharing.d.ts +6 -0
  53. package/dist/sharing.js +93 -0
  54. package/dist/snapshot.d.ts +16 -5
  55. package/dist/snapshot.js +6 -7
  56. package/dist/state.d.ts +0 -1
  57. package/dist/state.js +0 -1
  58. package/dist/svg-attributes.d.ts +12 -0
  59. package/dist/svg-attributes.js +1 -0
  60. package/dist/task.d.ts +9 -8
  61. package/dist/task.js +75 -47
  62. package/dist/tasks.d.ts +20 -15
  63. package/dist/tasks.js +39 -25
  64. package/dist/testing.d.ts +10 -15
  65. package/package.json +5 -1
package/dist/dom.js CHANGED
@@ -1,9 +1,36 @@
1
+ import attributeData from './dom-attributes.json' with { type: 'json' };
2
+ import { validateIdentities } from './collection.js';
1
3
  import { runAll, reportError, reportSafely } from './errors.js';
2
4
  import { eventEffects } from './effectEvent.js';
3
5
  import { traceBinding } from './diagnostics.js';
4
- import { shareValue } from './share.js';
6
+ import { shareData } from './sharing.js';
7
+ import { jsxComponent } from './jsx.js';
5
8
  import { startMount } from './mount.js';
6
9
  const equal = (a, b) => a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
10
+ // A model publication reconciles DOM first and controlled dependent properties second.
11
+ // Nested child scopes join the same synchronous commit; there is no reactive graph.
12
+ let commitDepth = 0;
13
+ const controlCommits = new Set();
14
+ function commitDom(work) {
15
+ commitDepth++;
16
+ try {
17
+ return work();
18
+ }
19
+ finally {
20
+ if (--commitDepth === 0) {
21
+ for (const apply of controlCommits) {
22
+ controlCommits.delete(apply);
23
+ apply();
24
+ }
25
+ }
26
+ }
27
+ }
28
+ function afterDom(apply) {
29
+ if (commitDepth)
30
+ controlCommits.add(apply);
31
+ else
32
+ apply();
33
+ }
7
34
  /** Compiler implementation. No implicit tracking, proxies, or per-binding subscriptions. */
8
35
  export class Scope {
9
36
  value;
@@ -27,7 +54,7 @@ export class Scope {
27
54
  return value;
28
55
  const next = dependencies();
29
56
  if (!previous || !equal(previous, next)) {
30
- value = previous ? shareValue(value, compute()) : compute();
57
+ value = previous ? shareData(value, compute()) : compute();
31
58
  traceBinding(source, previous, next, 'derive');
32
59
  previous = next;
33
60
  }
@@ -54,18 +81,20 @@ export class Scope {
54
81
  set(value) {
55
82
  if (this.disposed)
56
83
  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);
84
+ commitDom(() => {
85
+ this.value = value;
86
+ this.revision++;
87
+ for (const job of this.jobs) {
88
+ if (this.disposed)
89
+ break;
90
+ try {
91
+ job();
92
+ }
93
+ catch (error) {
94
+ reportSafely(this.report, error);
95
+ }
67
96
  }
68
- }
97
+ });
69
98
  }
70
99
  dispose() {
71
100
  if (this.disposed)
@@ -75,6 +104,14 @@ export class Scope {
75
104
  runAll(this.cleanups.splice(0).reverse(), this.report);
76
105
  }
77
106
  }
107
+ /** Compiler marker for mounting a view with an explicit model and message dispatcher. */
108
+ export const ViewBinding = /* @__PURE__ */ Object.assign(function ViewBinding(_props) {
109
+ throw new Error('ViewBinding reached runtime without the EffectWeb JSX compiler');
110
+ }, { [jsxComponent]: true });
111
+ /** Untyped callers get an actionable failure instead of silently losing child messages. */
112
+ export function unboundSend(_message) {
113
+ throw new Error('This view needs a dispatcher. Mount it through ViewBinding with model and send.');
114
+ }
78
115
  const contentBrand = Symbol('compiled content');
79
116
  /** Compiler marker: declare inside view(), or directly in a compiled component prop. */
80
117
  export function slot(_render) {
@@ -148,8 +185,14 @@ export function view(_render) {
148
185
  export function compiled(build) {
149
186
  return Object.assign(() => {
150
187
  throw new Error('Mount compiled views with mountView or inside another compiled view');
151
- }, { build });
188
+ }, { build, [jsxComponent]: true });
152
189
  }
190
+ /** Owned content in an element target, defaulting to the document body. */
191
+ export const Portal = /* @__PURE__ */ compiled((scope) => {
192
+ portal(scope, (child, parent, before) => {
193
+ text(child, parent, before, () => [child.value.children], () => child.value.children);
194
+ }, () => scope.value.mount);
195
+ });
153
196
  function markers(parent, before) {
154
197
  const start = document.createComment('');
155
198
  const end = document.createComment('');
@@ -210,25 +253,27 @@ function buildFragment(parent) {
210
253
  return fragment;
211
254
  }
212
255
  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
- };
256
+ return commitDom(() => {
257
+ const { start, end } = markers(parent, null);
258
+ const scope = new Scope(source.model(), source.send, options.onError);
259
+ let unsubscribe = () => { };
260
+ try {
261
+ const fragment = buildFragment(parent);
262
+ definition.build(scope, fragment, null);
263
+ parent.insertBefore(fragment, end);
264
+ unsubscribe = source.subscribe((model) => scope.set(model));
265
+ }
266
+ catch (error) {
267
+ scope.dispose();
268
+ remove(start, end);
269
+ throw error;
270
+ }
271
+ return () => {
272
+ if (scope.disposed)
273
+ return;
274
+ runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
275
+ };
276
+ });
232
277
  }
233
278
  export function element(parent, before, tag) {
234
279
  const doc = parent.ownerDocument ?? document;
@@ -239,6 +284,7 @@ export function element(parent, before, tag) {
239
284
  return node;
240
285
  }
241
286
  const classTokens = new WeakMap();
287
+ let booleanAttributes;
242
288
  const styleProperties = new WeakMap();
243
289
  function scalar(value) {
244
290
  if (value == null)
@@ -251,7 +297,11 @@ function scalar(value) {
251
297
  throw new Error('DOM values must be scalar. Render objects as compiled child views or domain collections.');
252
298
  }
253
299
  export function attribute(element, name, value) {
254
- const key = name === 'className' ? 'class' : name === 'tabIndex' ? 'tabindex' : name;
300
+ const aliases = attributeData.aliases;
301
+ const key = aliases[name] ?? name;
302
+ const booleanValues = attributeData.booleanValues;
303
+ if (typeof value === 'boolean')
304
+ value = booleanValues[key]?.[String(value)] ?? value;
255
305
  if (name === 'classList') {
256
306
  const tokens = (value ?? {});
257
307
  const next = new Set();
@@ -287,31 +337,19 @@ export function attribute(element, name, value) {
287
337
  if (element.value !== next)
288
338
  element.value = next;
289
339
  }
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));
340
+ else if ((typeof value === 'boolean' || value == null) &&
341
+ (booleanAttributes ??= new Set(attributeData.boolean)).has(key)) {
342
+ element.toggleAttribute(key, Boolean(value));
343
+ const properties = attributeData.properties;
344
+ const property = properties[key] ?? key;
345
+ if (typeof Reflect.get(element, property) === 'boolean')
346
+ Reflect.set(element, property, Boolean(value));
312
347
  }
313
348
  else if (value == null ||
314
- (value === false && !name.startsWith('aria-') && !name.startsWith('data-'))) {
349
+ (value === false &&
350
+ !attributeData.booleanish.includes(key) &&
351
+ !name.startsWith('aria-') &&
352
+ !name.startsWith('data-'))) {
315
353
  element.removeAttribute(key);
316
354
  }
317
355
  else {
@@ -322,8 +360,142 @@ export function attribute(element, name, value) {
322
360
  if (key === 'class')
323
361
  for (const token of classTokens.get(element) ?? [])
324
362
  element.classList.add(token);
325
- if (name === 'style' && (value == null || typeof value !== 'object'))
326
- styleProperties.delete(element);
363
+ if (name === 'style' && (value == null || typeof value !== 'object')) {
364
+ if (value == null)
365
+ styleProperties.delete(element);
366
+ else if ('style' in element)
367
+ styleProperties.set(element, new Set(Array.from(element.style)));
368
+ }
369
+ }
370
+ /** Compiler output for attributes whose unchanged value needs no DOM refresh. */
371
+ export function bindAttribute(scope, element, name, dependencies, read, source) {
372
+ let previous = dependencies();
373
+ let value = read();
374
+ attribute(element, name, value);
375
+ traceBinding(source, undefined, previous, 'binding');
376
+ if (!previous.length)
377
+ return;
378
+ // Keep the value cache in the dependency job itself, without an extra closure
379
+ // for every row. Controlled input properties keep using ordinary watches.
380
+ scope.jobs.push(() => {
381
+ const next = dependencies();
382
+ if (equal(previous, next))
383
+ return;
384
+ const nextValue = read();
385
+ if (!Object.is(value, nextValue)) {
386
+ attribute(element, name, nextValue);
387
+ value = nextValue;
388
+ }
389
+ traceBinding(source, previous, next, 'binding');
390
+ previous = next;
391
+ });
392
+ }
393
+ const controlRestorations = new WeakMap();
394
+ /** Handled edits can change a control even when dispatch publishes no new model. */
395
+ export function bindControl(scope, element, name, dependencies, read, source) {
396
+ let composing = false;
397
+ let deferred = false;
398
+ let pending;
399
+ const select = element.tagName === 'SELECT' && name === 'value';
400
+ const write = () => {
401
+ if (scope.disposed)
402
+ return;
403
+ try {
404
+ if (composing)
405
+ deferred = true;
406
+ else
407
+ attribute(element, name, read());
408
+ }
409
+ catch (error) {
410
+ reportSafely(scope.report, error);
411
+ }
412
+ };
413
+ const apply = () => {
414
+ if (select)
415
+ afterDom(write);
416
+ else
417
+ write();
418
+ };
419
+ let initialized = false;
420
+ let published;
421
+ scope.watch(dependencies, () => {
422
+ const next = read();
423
+ // A coarse dependency can change while this field's published value stays equal.
424
+ // Preserve unfinished blur drafts; handled edits still restore through apply below.
425
+ if (!initialized || !Object.is(published, next)) {
426
+ initialized = true;
427
+ published = next;
428
+ apply();
429
+ }
430
+ }, source);
431
+ if (select) {
432
+ // The selected value may stay equal while its options change on this publication.
433
+ scope.jobs.push(apply);
434
+ // Options may also belong to a separately updating component or a DOM host.
435
+ // Observe only option-affecting mutations; setting selectedness does not mutate attributes.
436
+ const observer = new MutationObserver(apply);
437
+ observer.observe(element, {
438
+ childList: true,
439
+ subtree: true,
440
+ characterData: true,
441
+ attributes: true,
442
+ attributeFilter: ['value', 'selected', 'disabled'],
443
+ });
444
+ scope.cleanups.push(() => {
445
+ observer.disconnect();
446
+ controlCommits.delete(write);
447
+ });
448
+ }
449
+ const restore = () => {
450
+ if (composing) {
451
+ deferred = true;
452
+ return;
453
+ }
454
+ if (pending !== undefined || scope.disposed)
455
+ return;
456
+ // Native dispatch may run microtasks between listeners. A task waits for all
457
+ // handlers (including ancestors) before restoring the latest model value.
458
+ pending = setTimeout(() => {
459
+ pending = undefined;
460
+ if (!scope.disposed) {
461
+ try {
462
+ apply();
463
+ }
464
+ catch (error) {
465
+ reportSafely(scope.report, error);
466
+ }
467
+ }
468
+ }, 0);
469
+ };
470
+ const start = () => {
471
+ composing = true;
472
+ };
473
+ const end = () => {
474
+ composing = false;
475
+ if (deferred) {
476
+ deferred = false;
477
+ restore();
478
+ }
479
+ };
480
+ const handled = (type) => {
481
+ // Clicking a text control must not commit its unfinished blur draft.
482
+ if (type !== 'click' || name === 'checked')
483
+ restore();
484
+ };
485
+ let restorations = controlRestorations.get(element);
486
+ if (!restorations)
487
+ controlRestorations.set(element, (restorations = new Set()));
488
+ restorations.add(handled);
489
+ element.addEventListener('compositionstart', start);
490
+ element.addEventListener('compositionend', end);
491
+ scope.cleanups.push(() => {
492
+ clearTimeout(pending);
493
+ restorations.delete(handled);
494
+ if (!restorations.size)
495
+ controlRestorations.delete(element);
496
+ element.removeEventListener('compositionstart', start);
497
+ element.removeEventListener('compositionend', end);
498
+ });
327
499
  }
328
500
  export function text(scope, parent, before, dependencies, read, source) {
329
501
  const node = document.createTextNode('');
@@ -331,7 +503,11 @@ export function text(scope, parent, before, dependencies, read, source) {
331
503
  let start;
332
504
  let content;
333
505
  scope.watch(dependencies, () => {
334
- const value = read();
506
+ let value = read();
507
+ if (Array.isArray(value)) {
508
+ validateContent(value);
509
+ value = { [contentBrand]: true, definition: arrayContent, value };
510
+ }
335
511
  if (isContent(value)) {
336
512
  node.data = '';
337
513
  if (content?.definition === value.definition) {
@@ -349,26 +525,93 @@ export function text(scope, parent, before, dependencies, read, source) {
349
525
  }
350
526
  content = {
351
527
  definition: value.definition,
352
- mounted: value.definition.mount(node.parentNode, node, value.value),
528
+ mounted: value.definition.mount(node.parentNode, node, value.value, scope.report),
353
529
  };
354
530
  return;
355
531
  }
532
+ if (value != null && typeof value === 'object')
533
+ throw new Error('Unsupported JSX content. Use compiled views or slots for markup, collections for entities, and an owned DOM host for native nodes.');
534
+ const next = value == null || typeof value === 'boolean' ? '' : scalar(value);
356
535
  if (content) {
357
536
  content.mounted.dispose();
358
537
  content = undefined;
359
538
  clear(start, node);
360
539
  }
361
- if (value != null && typeof value === 'object')
362
- throw new Error('Object rendered as text. Use collection(...).from(items).map(...) for entity lists.');
363
- const next = value == null || typeof value === 'boolean' ? '' : scalar(value);
364
540
  if (node.data !== next)
365
541
  node.data = next;
366
542
  }, source);
367
543
  }
544
+ function validateContent(value, active = new Set()) {
545
+ if (Array.isArray(value)) {
546
+ if (active.has(value))
547
+ throw new TypeError('JSX content arrays cannot contain cycles.');
548
+ active.add(value);
549
+ for (const child of value)
550
+ validateContent(child, active);
551
+ active.delete(value);
552
+ }
553
+ else if (!isContent(value) &&
554
+ value != null &&
555
+ !['string', 'number', 'boolean'].includes(typeof value)) {
556
+ throw new TypeError('Unsupported JSX content. Use an owned DOM host for native nodes and compiled views for objects.');
557
+ }
558
+ }
559
+ const arrayContent = {
560
+ mount: (_parent, before, value, report) => mountArray(report, before, value),
561
+ };
562
+ /** Runtime content arrays are positional; domain lists retain the explicit collection contract. */
563
+ function mountArray(report, before, initial) {
564
+ const cells = [];
565
+ const disposeCell = (cell) => {
566
+ cell.scope.dispose();
567
+ remove(cell.start, cell.end);
568
+ };
569
+ const set = (input) => {
570
+ const values = input;
571
+ validateContent(values);
572
+ while (cells.length > values.length)
573
+ disposeCell(cells.pop());
574
+ for (let index = 0; index < values.length; index++) {
575
+ const cell = cells[index];
576
+ if (cell) {
577
+ if (!Object.is(cell.scope.value, values[index]))
578
+ cell.scope.set(values[index]);
579
+ continue;
580
+ }
581
+ const scope = new Scope(values[index], unboundSend, report);
582
+ const fragment = buildFragment(before.parentNode);
583
+ const range = markers(fragment, null);
584
+ try {
585
+ text(scope, fragment, range.end, () => [scope.value], () => scope.value);
586
+ before.parentNode.insertBefore(fragment, before);
587
+ cells.push({ scope, ...range });
588
+ }
589
+ catch (error) {
590
+ scope.dispose();
591
+ throw error;
592
+ }
593
+ }
594
+ };
595
+ const dispose = () => {
596
+ for (const cell of cells.splice(0))
597
+ disposeCell(cell);
598
+ };
599
+ try {
600
+ set(initial);
601
+ }
602
+ catch (error) {
603
+ dispose();
604
+ throw error;
605
+ }
606
+ return { set, dispose };
607
+ }
368
608
  export function event(scope, element, name, handler) {
369
609
  let effects;
370
- const capture = name.endsWith('Capture');
371
- const type = name.slice(2, capture ? -7 : undefined).toLowerCase();
610
+ const capture = name.endsWith('Capture') && name !== 'onGotPointerCapture' && name !== 'onLostPointerCapture';
611
+ const nativeName = name.slice(2, capture ? -7 : undefined);
612
+ const type = nativeName === 'Begin' || nativeName === 'End' || nativeName === 'Repeat'
613
+ ? `${nativeName.toLowerCase()}Event`
614
+ : nativeName.toLowerCase();
372
615
  const listener = (event) => {
373
616
  if (!scope.disposed) {
374
617
  try {
@@ -381,6 +624,13 @@ export function event(scope, element, name, handler) {
381
624
  catch (error) {
382
625
  reportSafely(scope.report, error);
383
626
  }
627
+ finally {
628
+ // Restore only after an application handler commits or rejects an edit.
629
+ // The target also covers handlers delegated to an ancestor.
630
+ if (event.target && ['input', 'change', 'blur', 'focusout', 'click'].includes(type))
631
+ for (const restore of controlRestorations.get(event.target) ?? [])
632
+ restore(type);
633
+ }
384
634
  }
385
635
  };
386
636
  element.addEventListener(type, listener, capture);
@@ -389,6 +639,84 @@ export function event(scope, element, name, handler) {
389
639
  effects?.dispose();
390
640
  });
391
641
  }
642
+ /** One handler identity owns its requests, regardless of the JSX attribute spelling. */
643
+ export function bindEvent(scope, element, name, dependencies, read) {
644
+ let current;
645
+ let owned;
646
+ scope.cleanups.push(() => owned?.dispose());
647
+ scope.watch(dependencies, () => {
648
+ const next = read();
649
+ if (next != null && typeof next !== 'function')
650
+ throw new TypeError(`Event ${name} must be a synchronous handler.`);
651
+ if (Object.is(current, next))
652
+ return;
653
+ owned?.dispose();
654
+ owned = undefined;
655
+ current = next;
656
+ if (typeof next === 'function') {
657
+ const handler = next;
658
+ owned = new Scope(next, scope.send, scope.report);
659
+ event(owned, element, name, handler);
660
+ }
661
+ });
662
+ }
663
+ /** Reconcile merged JSX attributes. Each host, event and control owns its cleanup. */
664
+ export function bindAttributes(scope, element, dependencies, read) {
665
+ const bindings = new Map();
666
+ let previous = {};
667
+ const isEvent = (name) => /^on[A-Z]/u.test(name);
668
+ const isControl = (name) => (name === 'value' && ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)) ||
669
+ (name === 'checked' && element.tagName === 'INPUT');
670
+ scope.cleanups.push(() => {
671
+ runAll([...bindings.values()].reverse().map((binding) => () => binding.dispose()), scope.report);
672
+ bindings.clear();
673
+ });
674
+ scope.watch(dependencies, () => {
675
+ const next = read();
676
+ for (const name of Object.keys(next)) {
677
+ if (['key', 'ref', 'innerHTML', 'children'].includes(name))
678
+ throw new Error(`Spread attribute ${name} is unsupported. Use collections, DOM hosts, or JSX children.`);
679
+ if (isEvent(name) && next[name] != null && typeof next[name] !== 'function')
680
+ throw new Error(`Spread event ${name} must be a synchronous handler.`);
681
+ }
682
+ for (const name of Object.keys(previous)) {
683
+ if (Object.hasOwn(next, name))
684
+ continue;
685
+ bindings.get(name)?.dispose();
686
+ bindings.delete(name);
687
+ if (name !== 'use' && !isEvent(name))
688
+ attribute(element, name, undefined);
689
+ }
690
+ for (const [name, value] of Object.entries(next)) {
691
+ if (name !== 'use' && !isEvent(name) && !isControl(name)) {
692
+ attribute(element, name, value);
693
+ continue;
694
+ }
695
+ let binding = bindings.get(name);
696
+ if (binding && Object.is(binding.value, value))
697
+ continue;
698
+ if (binding) {
699
+ binding.set(value);
700
+ continue;
701
+ }
702
+ const owned = new Scope(value, scope.send, scope.report);
703
+ bindings.set(name, owned);
704
+ if (name === 'use')
705
+ attach(owned, element, () => [owned.value], () => owned.value);
706
+ else if (isControl(name))
707
+ bindControl(owned, element, name, () => [owned.value], () => owned.value);
708
+ else
709
+ bindEvent(owned, element, name, () => [owned.value], () => owned.value);
710
+ }
711
+ previous = next;
712
+ });
713
+ if (element.tagName === 'SELECT')
714
+ scope.jobs.push(() => {
715
+ const binding = bindings.get('value');
716
+ if (binding)
717
+ binding.set(binding.value);
718
+ });
719
+ }
392
720
  export function branch(scope, parent, before, choose, yes, no) {
393
721
  const { start, end } = markers(parent, before);
394
722
  let child;
@@ -430,19 +758,38 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
430
758
  const next = read();
431
759
  const nextOuter = outer();
432
760
  const outerChanged = !equal(previousOuter, nextOuter);
433
- if (next === previousList && !outerChanged)
761
+ if (next === previousList) {
762
+ if (outerChanged)
763
+ for (const row of rows.values())
764
+ row.scope.set(row.scope.value);
765
+ previousOuter = nextOuter;
434
766
  return;
767
+ }
435
768
  const collection = 'items' in next ? next : undefined;
436
769
  const items = collection ? collection.items : next;
437
- const identities = items.map((item, index) => {
770
+ // A list filling its container can clear the DOM in one operation. Root lists
771
+ // and lists with adjacent content retain their bounded per-row removal path.
772
+ if (!items.length &&
773
+ rows.size &&
774
+ target instanceof Element &&
775
+ target.firstChild === rows.get(previousIdentities[0])?.start &&
776
+ target.lastChild === end) {
777
+ for (const row of rows.values())
778
+ row.scope.dispose();
779
+ target.replaceChildren(end);
780
+ rows.clear();
781
+ previousList = next;
782
+ previousOuter = nextOuter;
783
+ previousIdentities = [];
784
+ return;
785
+ }
786
+ const identities = validateIdentities(items, (item, index) => {
438
787
  const key = collection ? collection.identity(item, index) : item;
439
788
  if (typeof key !== 'string' && typeof key !== 'number')
440
789
  throw new Error('Object lists need domain identity. Declare collection(identity) once; do not add JSX keys.');
441
790
  return key;
442
791
  });
443
792
  const keep = new Set(identities);
444
- if (keep.size !== identities.length)
445
- throw new Error('Duplicate collection identity. Identity must be unique within the rendered collection.');
446
793
  for (const [key, row] of rows) {
447
794
  if (keep.has(key))
448
795
  continue;
@@ -453,7 +800,7 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
453
800
  for (let index = 0; index < items.length; index++) {
454
801
  const key = identities[index];
455
802
  const row = rows.get(key);
456
- const item = row ? shareValue(row.scope.value[0], items[index]) : items[index];
803
+ const item = row ? shareData(row.scope.value[0], items[index]) : items[index];
457
804
  if (!row) {
458
805
  const fragment = buildFragment(target);
459
806
  const range = markers(fragment, null);
@@ -552,6 +899,53 @@ export function child(scope, parent, before, definition, dependencies, model, se
552
899
  child.set(next);
553
900
  });
554
901
  }
902
+ /** A late-bound compiled definition owns one replaceable DOM region. */
903
+ export function viewRegion(scope, parent, before) {
904
+ const { start, end } = markers(parent, before);
905
+ let active;
906
+ let definition;
907
+ scope.jobs.push(() => active?.set(scope.value));
908
+ scope.cleanups.push(() => {
909
+ active?.dispose();
910
+ remove(start, end);
911
+ });
912
+ return (next) => commitDom(() => {
913
+ if (scope.disposed)
914
+ return;
915
+ if (definition === next) {
916
+ active?.set(scope.value);
917
+ return;
918
+ }
919
+ const previous = active;
920
+ active = undefined;
921
+ definition = undefined;
922
+ previous?.dispose();
923
+ if (scope.disposed)
924
+ return;
925
+ clear(start, end);
926
+ if (!next)
927
+ return;
928
+ const child = new Scope(scope.value, scope.send, scope.report);
929
+ const fragment = buildFragment(end.parentNode);
930
+ active = child;
931
+ definition = next;
932
+ try {
933
+ next.build(child, fragment, null);
934
+ if (scope.disposed || active !== child)
935
+ child.dispose();
936
+ else
937
+ end.parentNode.insertBefore(fragment, end);
938
+ }
939
+ catch (error) {
940
+ child.dispose();
941
+ if (active === child) {
942
+ active = undefined;
943
+ definition = undefined;
944
+ }
945
+ throw error;
946
+ }
947
+ });
948
+ }
555
949
  export function attach(scope, element, dependencies, read) {
556
950
  let generation = 0;
557
951
  let active;
@@ -572,7 +966,11 @@ export function attach(scope, element, dependencies, read) {
572
966
  queueMicrotask(() => {
573
967
  if (!scope.disposed && token === generation) {
574
968
  try {
575
- active = startMount(element, mount, scope.report);
969
+ const acquired = startMount(element, mount, scope.report);
970
+ if (scope.disposed || token !== generation)
971
+ acquired.dispose();
972
+ else
973
+ active = acquired;
576
974
  }
577
975
  catch (error) {
578
976
  reportSafely(scope.report, error);
@@ -581,20 +979,74 @@ export function attach(scope, element, dependencies, read) {
581
979
  });
582
980
  });
583
981
  }
584
- export function portal(scope, build) {
585
- const host = document.createElement('div');
586
- host.style.display = 'contents';
587
- document.body.appendChild(host);
588
- const child = new Scope(scope.value, scope.send, scope.report);
982
+ export function portal(scope, build, mount = () => undefined) {
983
+ let host;
984
+ let child;
589
985
  scope.cleanups.push(() => {
590
- child.dispose();
591
- host.remove();
986
+ child?.dispose();
987
+ host?.remove();
592
988
  });
593
- build(child, host, null);
594
- scope.jobs.push(() => child.set(scope.value));
989
+ const update = () => {
990
+ const target = mount() ?? document.body;
991
+ const svg = svgChildren(target);
992
+ if (!host || (host.namespaceURI === svgNamespace) !== svg) {
993
+ child?.dispose();
994
+ host?.remove();
995
+ if (scope.disposed)
996
+ return;
997
+ host = svg
998
+ ? target.ownerDocument.createElementNS(svgNamespace, 'g')
999
+ : target.ownerDocument.createElement('div');
1000
+ host.style.display = 'contents';
1001
+ child = new Scope(scope.value, scope.send, scope.report);
1002
+ target.appendChild(host);
1003
+ build(child, host, null);
1004
+ return;
1005
+ }
1006
+ if (host.parentNode !== target) {
1007
+ const focused = host.ownerDocument.activeElement;
1008
+ const move = Reflect.get(target, 'moveBefore');
1009
+ if (typeof move === 'function' &&
1010
+ target.isConnected &&
1011
+ host.isConnected &&
1012
+ target.ownerDocument === host.ownerDocument)
1013
+ move.call(target, host, null);
1014
+ else
1015
+ target.appendChild(host);
1016
+ if (focused instanceof HTMLElement &&
1017
+ focused.isConnected &&
1018
+ focused.ownerDocument.activeElement !== focused)
1019
+ focused.focus({ preventScroll: true });
1020
+ }
1021
+ child.set(scope.value);
1022
+ };
1023
+ scope.jobs.push(update);
1024
+ update();
1025
+ }
1026
+ // HTML parsing supplies the initial nodes. Unusual placements, such as mounting
1027
+ // an HTML-named view inside SVG, still follow the same namespace rules as element().
1028
+ function placeTemplate(parent, node) {
1029
+ if (node.nodeType !== 1) {
1030
+ parent.appendChild(node);
1031
+ return;
1032
+ }
1033
+ const source = node;
1034
+ const namespace = source.localName === 'svg' || svgChildren(parent)
1035
+ ? svgNamespace
1036
+ : 'http://www.w3.org/1999/xhtml';
1037
+ if (source.namespaceURI === namespace) {
1038
+ parent.appendChild(source);
1039
+ return;
1040
+ }
1041
+ const target = element(parent, null, source.localName);
1042
+ for (const attr of source.attributes)
1043
+ target.setAttribute(attr.name, attr.value);
1044
+ while (source.firstChild)
1045
+ placeTemplate(target, source.firstChild);
1046
+ source.remove();
595
1047
  }
596
1048
  /** Build static markup once per document/namespace, then clone native nodes at each use. */
597
- export function template(build) {
1049
+ export function template(build, depth = 0) {
598
1050
  const documents = new WeakMap();
599
1051
  return (parent, before) => {
600
1052
  const doc = parent.ownerDocument ?? document;
@@ -609,7 +1061,16 @@ export function template(build) {
609
1061
  const host = svg
610
1062
  ? doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
611
1063
  : doc.createElement('div');
612
- build(host, null);
1064
+ if (typeof build === 'string') {
1065
+ const source = doc.createElement('template');
1066
+ source.innerHTML = build;
1067
+ let node = source.content.firstChild;
1068
+ for (let index = 0; index < depth; index++)
1069
+ node = node.firstChild;
1070
+ placeTemplate(host, node);
1071
+ }
1072
+ else
1073
+ build(host, null);
613
1074
  if (host.firstChild && !host.firstChild.nextSibling) {
614
1075
  fragment = host.removeChild(host.firstChild);
615
1076
  }
@@ -620,7 +1081,9 @@ export function template(build) {
620
1081
  }
621
1082
  variants.set(svg, fragment);
622
1083
  }
623
- parent.insertBefore(fragment.cloneNode(true), before);
1084
+ const clone = fragment.cloneNode(true);
1085
+ parent.insertBefore(clone, before);
1086
+ return clone;
624
1087
  };
625
1088
  }
626
1089
  export function literal(parent, before, value) {