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.
- package/LICENSE +1 -1
- package/README.md +94 -2
- package/dist/AsyncContent.d.ts +2 -2
- package/dist/AsyncContent.js +36 -24
- package/dist/actions.d.ts +4 -4
- package/dist/actions.js +1 -1
- package/dist/cache-internals.d.ts +14 -0
- package/dist/cache-internals.js +11 -0
- package/dist/cache.d.ts +17 -15
- package/dist/cache.js +98 -27
- package/dist/collection.d.ts +10 -10
- package/dist/collection.js +32 -6
- package/dist/component.d.ts +8 -7
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +167 -1
- package/dist/dom-attributes.json +71 -0
- package/dist/dom.d.ts +36 -13
- package/dist/dom.js +547 -84
- package/dist/form.d.ts +61 -0
- package/dist/form.js +98 -0
- package/dist/html-attributes.d.ts +602 -0
- package/dist/html-attributes.js +1 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +7 -5
- package/dist/jsx.d.ts +165 -33
- package/dist/jsx.js +2 -1
- package/dist/lazy.d.ts +23 -0
- package/dist/lazy.js +58 -0
- package/dist/load.d.ts +3 -4
- package/dist/mount.d.ts +8 -11
- package/dist/mount.js +1 -3
- package/dist/owner.d.ts +11 -9
- package/dist/owner.js +27 -38
- package/dist/pages.d.ts +23 -23
- package/dist/pages.js +15 -10
- package/dist/program.d.ts +19 -10
- package/dist/program.js +172 -66
- package/dist/query-internals.d.ts +11 -0
- package/dist/query-internals.js +10 -0
- package/dist/query.d.ts +24 -20
- package/dist/query.js +60 -7
- package/dist/resource.d.ts +4 -10
- package/dist/resource.js +14 -5
- package/dist/runtime.d.ts +1 -2
- package/dist/runtime.js +3 -3
- package/dist/session.d.ts +12 -18
- package/dist/session.js +15 -15
- package/dist/share.d.ts +5 -4
- package/dist/share.js +5 -44
- package/dist/sharing.d.ts +6 -0
- package/dist/sharing.js +93 -0
- package/dist/snapshot.d.ts +16 -5
- package/dist/snapshot.js +6 -7
- package/dist/state.d.ts +0 -1
- package/dist/state.js +0 -1
- package/dist/svg-attributes.d.ts +12 -0
- package/dist/svg-attributes.js +1 -0
- package/dist/task.d.ts +9 -8
- package/dist/task.js +75 -47
- package/dist/tasks.d.ts +20 -15
- package/dist/tasks.js +39 -25
- package/dist/testing.d.ts +10 -15
- 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 {
|
|
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 ?
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
'
|
|
296
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|
591
|
-
host
|
|
986
|
+
child?.dispose();
|
|
987
|
+
host?.remove();
|
|
592
988
|
});
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|