effectweb 0.2.3 → 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 (62) hide show
  1. package/README.md +93 -1
  2. package/dist/AsyncContent.d.ts +2 -2
  3. package/dist/AsyncContent.js +6 -3
  4. package/dist/actions.d.ts +4 -3
  5. package/dist/actions.js +1 -0
  6. package/dist/cache-internals.d.ts +14 -0
  7. package/dist/cache-internals.js +11 -0
  8. package/dist/cache.d.ts +17 -14
  9. package/dist/cache.js +98 -26
  10. package/dist/collection.d.ts +10 -9
  11. package/dist/collection.js +32 -6
  12. package/dist/component.d.ts +8 -7
  13. package/dist/diagnostics.d.ts +25 -0
  14. package/dist/diagnostics.js +167 -1
  15. package/dist/dom-attributes.json +71 -0
  16. package/dist/dom.d.ts +33 -12
  17. package/dist/dom.js +466 -80
  18. package/dist/form.d.ts +61 -0
  19. package/dist/form.js +98 -0
  20. package/dist/html-attributes.d.ts +602 -0
  21. package/dist/html-attributes.js +1 -0
  22. package/dist/http.d.ts +5 -0
  23. package/dist/http.js +7 -0
  24. package/dist/index.d.ts +12 -10
  25. package/dist/index.js +7 -5
  26. package/dist/jsx.d.ts +165 -33
  27. package/dist/jsx.js +2 -1
  28. package/dist/lazy.d.ts +23 -0
  29. package/dist/lazy.js +58 -0
  30. package/dist/load.d.ts +3 -3
  31. package/dist/mount.d.ts +8 -11
  32. package/dist/mount.js +1 -3
  33. package/dist/owner.d.ts +11 -9
  34. package/dist/owner.js +27 -36
  35. package/dist/pages.d.ts +23 -22
  36. package/dist/pages.js +15 -9
  37. package/dist/program.d.ts +19 -9
  38. package/dist/program.js +145 -49
  39. package/dist/query-internals.d.ts +11 -0
  40. package/dist/query-internals.js +10 -0
  41. package/dist/query.d.ts +24 -19
  42. package/dist/query.js +60 -7
  43. package/dist/resource.d.ts +4 -10
  44. package/dist/resource.js +14 -5
  45. package/dist/runtime.d.ts +1 -2
  46. package/dist/runtime.js +3 -3
  47. package/dist/session.d.ts +12 -15
  48. package/dist/session.js +15 -12
  49. package/dist/share.d.ts +5 -4
  50. package/dist/share.js +5 -44
  51. package/dist/sharing.d.ts +6 -0
  52. package/dist/sharing.js +93 -0
  53. package/dist/snapshot.d.ts +16 -5
  54. package/dist/snapshot.js +6 -7
  55. package/dist/svg-attributes.d.ts +12 -0
  56. package/dist/svg-attributes.js +1 -0
  57. package/dist/task.d.ts +9 -8
  58. package/dist/task.js +75 -47
  59. package/dist/tasks.d.ts +20 -15
  60. package/dist/tasks.js +39 -25
  61. package/dist/testing.d.ts +10 -14
  62. 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,12 @@ 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
+ }
327
369
  }
328
370
  /** Compiler output for attributes whose unchanged value needs no DOM refresh. */
329
371
  export function bindAttribute(scope, element, name, dependencies, read, source) {
@@ -348,13 +390,124 @@ export function bindAttribute(scope, element, name, dependencies, read, source)
348
390
  previous = next;
349
391
  });
350
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
+ });
499
+ }
351
500
  export function text(scope, parent, before, dependencies, read, source) {
352
501
  const node = document.createTextNode('');
353
502
  parent.insertBefore(node, before);
354
503
  let start;
355
504
  let content;
356
505
  scope.watch(dependencies, () => {
357
- const value = read();
506
+ let value = read();
507
+ if (Array.isArray(value)) {
508
+ validateContent(value);
509
+ value = { [contentBrand]: true, definition: arrayContent, value };
510
+ }
358
511
  if (isContent(value)) {
359
512
  node.data = '';
360
513
  if (content?.definition === value.definition) {
@@ -372,26 +525,93 @@ export function text(scope, parent, before, dependencies, read, source) {
372
525
  }
373
526
  content = {
374
527
  definition: value.definition,
375
- mounted: value.definition.mount(node.parentNode, node, value.value),
528
+ mounted: value.definition.mount(node.parentNode, node, value.value, scope.report),
376
529
  };
377
530
  return;
378
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);
379
535
  if (content) {
380
536
  content.mounted.dispose();
381
537
  content = undefined;
382
538
  clear(start, node);
383
539
  }
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
540
  if (node.data !== next)
388
541
  node.data = next;
389
542
  }, source);
390
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
+ }
391
608
  export function event(scope, element, name, handler) {
392
609
  let effects;
393
- const capture = name.endsWith('Capture');
394
- 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();
395
615
  const listener = (event) => {
396
616
  if (!scope.disposed) {
397
617
  try {
@@ -404,6 +624,13 @@ export function event(scope, element, name, handler) {
404
624
  catch (error) {
405
625
  reportSafely(scope.report, error);
406
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
+ }
407
634
  }
408
635
  };
409
636
  element.addEventListener(type, listener, capture);
@@ -412,6 +639,84 @@ export function event(scope, element, name, handler) {
412
639
  effects?.dispose();
413
640
  });
414
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
+ }
415
720
  export function branch(scope, parent, before, choose, yes, no) {
416
721
  const { start, end } = markers(parent, before);
417
722
  let child;
@@ -478,15 +783,13 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
478
783
  previousIdentities = [];
479
784
  return;
480
785
  }
481
- const identities = items.map((item, index) => {
786
+ const identities = validateIdentities(items, (item, index) => {
482
787
  const key = collection ? collection.identity(item, index) : item;
483
788
  if (typeof key !== 'string' && typeof key !== 'number')
484
789
  throw new Error('Object lists need domain identity. Declare collection(identity) once; do not add JSX keys.');
485
790
  return key;
486
791
  });
487
792
  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
793
  for (const [key, row] of rows) {
491
794
  if (keep.has(key))
492
795
  continue;
@@ -497,7 +800,7 @@ export function each(scope, parent, before, read, outer, indexUsed, build) {
497
800
  for (let index = 0; index < items.length; index++) {
498
801
  const key = identities[index];
499
802
  const row = rows.get(key);
500
- 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];
501
804
  if (!row) {
502
805
  const fragment = buildFragment(target);
503
806
  const range = markers(fragment, null);
@@ -596,6 +899,53 @@ export function child(scope, parent, before, definition, dependencies, model, se
596
899
  child.set(next);
597
900
  });
598
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
+ }
599
949
  export function attach(scope, element, dependencies, read) {
600
950
  let generation = 0;
601
951
  let active;
@@ -616,7 +966,11 @@ export function attach(scope, element, dependencies, read) {
616
966
  queueMicrotask(() => {
617
967
  if (!scope.disposed && token === generation) {
618
968
  try {
619
- 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;
620
974
  }
621
975
  catch (error) {
622
976
  reportSafely(scope.report, error);
@@ -625,17 +979,49 @@ export function attach(scope, element, dependencies, read) {
625
979
  });
626
980
  });
627
981
  }
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);
982
+ export function portal(scope, build, mount = () => undefined) {
983
+ let host;
984
+ let child;
633
985
  scope.cleanups.push(() => {
634
- child.dispose();
635
- host.remove();
986
+ child?.dispose();
987
+ host?.remove();
636
988
  });
637
- build(child, host, null);
638
- 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();
639
1025
  }
640
1026
  // HTML parsing supplies the initial nodes. Unusual placements, such as mounting
641
1027
  // an HTML-named view inside SVG, still follow the same namespace rules as element().