@webjsdev/core 0.7.45 → 0.7.47
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/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -0
- package/index.d.ts +4 -0
- package/index.js +5 -0
- package/package.json +2 -2
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +137 -2
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1257 -0
- package/src/optimistic.js +7 -2
- package/src/render-client.js +1004 -107
- package/src/render-server.js +847 -70
- package/src/router-client.js +338 -23
- package/src/webjs-config.d.ts +49 -17
package/src/render-client.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { isTemplate, MARKER } from './html.js';
|
|
2
2
|
import { BINDING_PREFIXES, isBindingPrefix } from './binding-prefixes.js';
|
|
3
3
|
import { escapeAttr } from './escape.js';
|
|
4
|
+
import {
|
|
5
|
+
assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
|
|
6
|
+
reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName,
|
|
7
|
+
assertSubmitterType, assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
|
|
8
|
+
assertSubmitterHasNoFormAttribute, assertSingleSubmitterAction,
|
|
9
|
+
assertSubmitterFormIsBound, assertSubmitterSubmission, assertBoundFormSubmitters,
|
|
10
|
+
assertConvergentSubmitter, isSubmitterReflectedProp,
|
|
11
|
+
formActionId, assertIdentifiableAction, FORM_ACTION_FIELD,
|
|
12
|
+
} from './form-action.js';
|
|
4
13
|
import { isRepeat } from './repeat.js';
|
|
5
14
|
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
|
|
6
15
|
import { Signal } from './signal.js';
|
|
@@ -63,8 +72,52 @@ function commitInto(node, fn) {
|
|
|
63
72
|
* the `repeat()` directive.
|
|
64
73
|
*/
|
|
65
74
|
|
|
66
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* One `method` / `enctype` hole on a candidate bound form. `statics` / `group`
|
|
77
|
+
* are carried for a mixed attribute, whose value is the concatenation of its
|
|
78
|
+
* static pieces and every one of its holes.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {{ i: number, kind: string, statics?: string[], group?: number[] }} FormAttrPart
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* What one `<form action=${...}>` in a template needs at reconcile time (#1155).
|
|
85
|
+
* Every field is a property of the TEMPLATE, computed once per template literal
|
|
86
|
+
* call site, so it cannot drift out of step with the live DOM.
|
|
87
|
+
*
|
|
88
|
+
* @typedef {{
|
|
89
|
+
* actionIdxs: number[],
|
|
90
|
+
* duplicateAction: boolean,
|
|
91
|
+
* staticAction: boolean,
|
|
92
|
+
* authoredName: boolean,
|
|
93
|
+
* authoredValue: boolean,
|
|
94
|
+
* authoredForm: boolean,
|
|
95
|
+
* nameParts: {i: number, kind: string}[],
|
|
96
|
+
* valueParts: {i: number, kind: string}[],
|
|
97
|
+
* propAttrs: string[],
|
|
98
|
+
* staticMethod: string | null,
|
|
99
|
+
* staticEnctype: string | null,
|
|
100
|
+
* methodParts: FormAttrPart[],
|
|
101
|
+
* enctypeParts: FormAttrPart[],
|
|
102
|
+
* }} FormActionRecord
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[], formActions: FormActionRecord[] | null }>} */
|
|
67
106
|
const templateCache = new WeakMap();
|
|
107
|
+
/**
|
|
108
|
+
* Forms whose `action` hole currently holds a function (#1207). Read by a
|
|
109
|
+
* submitter that reconciles before its form does, so it can tell a form that is
|
|
110
|
+
* about to be bound from one that never will be. Keyed weakly so a detached
|
|
111
|
+
* form is collected with its entry.
|
|
112
|
+
* @type {WeakMap<HTMLFormElement, unknown>}
|
|
113
|
+
*/
|
|
114
|
+
const formActionCandidates = new WeakMap();
|
|
115
|
+
/**
|
|
116
|
+
* Submitters this renderer stamped with an identity, so a later release removes
|
|
117
|
+
* only the framework's own `name` / `value` and never an author's.
|
|
118
|
+
* @type {WeakMap<Element, string>}
|
|
119
|
+
*/
|
|
120
|
+
const submitterActionBindings = new WeakMap();
|
|
68
121
|
const INSTANCE = Symbol.for('webjs.instance');
|
|
69
122
|
|
|
70
123
|
/**
|
|
@@ -97,6 +150,54 @@ const INSTANCE = Symbol.for('webjs.instance');
|
|
|
97
150
|
* } BoundPart
|
|
98
151
|
*/
|
|
99
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The container the in-progress `render()` is committing into, or null
|
|
155
|
+
* outside one. Every part created or applied during that render belongs to
|
|
156
|
+
* THIS container's component, which is what an out-of-band commit needs to
|
|
157
|
+
* know to reach the right error boundary. A structural parent walk cannot
|
|
158
|
+
* work it out: `html`<child-el>${watch(sig)}</child-el>`` puts the part in the
|
|
159
|
+
* PARENT's template but inside the child's tag, so the walk meets the child
|
|
160
|
+
* first. Same reason `SLOT_OWNER` exists.
|
|
161
|
+
* @type {any}
|
|
162
|
+
*/
|
|
163
|
+
let currentRenderRoot = null;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The element carrying the error boundary for a render root. A ShadowRoot has
|
|
167
|
+
* no boundary of its own; its `.host` is the component.
|
|
168
|
+
* @param {any} root
|
|
169
|
+
* @returns {any}
|
|
170
|
+
*/
|
|
171
|
+
function boundaryOwnerOf(root) {
|
|
172
|
+
if (!root) return null;
|
|
173
|
+
if (root.nodeType === 11 && root.host) return root.host;
|
|
174
|
+
return root;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run an OUT-OF-BAND commit (a `watch` notify microtask, an `until`
|
|
179
|
+
* resolution) with the part's owner installed as the current render root.
|
|
180
|
+
*
|
|
181
|
+
* Without this the commit runs with no owner in scope, so any `watch` /
|
|
182
|
+
* `until` nested INSIDE the template it commits is installed unstamped and
|
|
183
|
+
* its own later throw escapes to the window. A directive nested in that
|
|
184
|
+
* template belongs to the same component as the part committing it, which is
|
|
185
|
+
* exactly the owner already recorded here.
|
|
186
|
+
*
|
|
187
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
188
|
+
* @param {() => void} fn
|
|
189
|
+
*/
|
|
190
|
+
function commitOutOfBand(part, fn) {
|
|
191
|
+
const owner = /** @type any */ (part).__commitOwner;
|
|
192
|
+
const prev = currentRenderRoot;
|
|
193
|
+
if (owner) currentRenderRoot = owner;
|
|
194
|
+
try {
|
|
195
|
+
fn();
|
|
196
|
+
} finally {
|
|
197
|
+
currentRenderRoot = prev;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
100
201
|
/**
|
|
101
202
|
* Render a value into a container, reusing DOM where possible.
|
|
102
203
|
*
|
|
@@ -112,6 +213,8 @@ export function render(value, container) {
|
|
|
112
213
|
// between a renderer commit and an author write.
|
|
113
214
|
const prevRendering = host[RENDERING];
|
|
114
215
|
host[RENDERING] = true;
|
|
216
|
+
const prevRenderRoot = currentRenderRoot;
|
|
217
|
+
currentRenderRoot = container;
|
|
115
218
|
try {
|
|
116
219
|
const prev = host[INSTANCE];
|
|
117
220
|
|
|
@@ -159,6 +262,7 @@ export function render(value, container) {
|
|
|
159
262
|
}
|
|
160
263
|
container.appendChild(document.createTextNode(String(value)));
|
|
161
264
|
} finally {
|
|
265
|
+
currentRenderRoot = prevRenderRoot;
|
|
162
266
|
host[RENDERING] = prevRendering;
|
|
163
267
|
// Outermost window closing: drain this commit's childList records off the
|
|
164
268
|
// slot backstop (drainRendererBackstop processes them with a renderer-output
|
|
@@ -399,10 +503,13 @@ function compile(tr) {
|
|
|
399
503
|
// discovery step adds are picked up in the same path-recording walk.
|
|
400
504
|
discoverSlots(templateEl.content, parts);
|
|
401
505
|
|
|
402
|
-
// Walk the parsed fragment and record DOM paths for each part.
|
|
403
|
-
|
|
506
|
+
// Walk the parsed fragment and record DOM paths for each part. It also
|
|
507
|
+
// returns the per-template form-action records (#1155), null when the
|
|
508
|
+
// template contains no bound-form candidate, which is the overwhelmingly
|
|
509
|
+
// common case and keeps the reconcile out of every other template's path.
|
|
510
|
+
const formActions = assignPaths(templateEl.content, parts);
|
|
404
511
|
|
|
405
|
-
cached = { templateEl, parts };
|
|
512
|
+
cached = { templateEl, parts, formActions };
|
|
406
513
|
templateCache.set(strings, cached);
|
|
407
514
|
return cached;
|
|
408
515
|
}
|
|
@@ -460,6 +567,8 @@ function discoverSlots(root, parts) {
|
|
|
460
567
|
function assignPaths(root, parts) {
|
|
461
568
|
/** @type {number[]} */
|
|
462
569
|
const path = [];
|
|
570
|
+
/** @type {FormActionRecord[]} */
|
|
571
|
+
const formActions = [];
|
|
463
572
|
/** @param {Node} node */
|
|
464
573
|
function visit(node) {
|
|
465
574
|
for (let i = 0; i < node.childNodes.length; i++) {
|
|
@@ -478,15 +587,30 @@ function assignPaths(root, parts) {
|
|
|
478
587
|
const el = /** @type Element */ (child);
|
|
479
588
|
// Sentinel attribute?
|
|
480
589
|
const toRemove = [];
|
|
590
|
+
/** Every part bound to THIS element, which only this walk can see. */
|
|
591
|
+
/** @type {{ idx: number, kind: string, name: string }[]} */
|
|
592
|
+
const onEl = [];
|
|
481
593
|
for (const attr of el.attributes) {
|
|
482
594
|
if (attr.name.startsWith(`data-${MARKER}`)) {
|
|
483
595
|
const idx = Number(attr.name.slice(`data-${MARKER}`.length));
|
|
484
596
|
if (parts[idx] && parts[idx].kind !== 'child') {
|
|
485
597
|
parts[idx].path = path.slice();
|
|
598
|
+
onEl.push({ idx, kind: parts[idx].kind, name: parts[idx].name || '' });
|
|
486
599
|
}
|
|
487
600
|
toRemove.push(attr.name);
|
|
488
601
|
}
|
|
489
602
|
}
|
|
603
|
+
// #1155 / #1207: record what a bound form or submitter needs, while the
|
|
604
|
+
// STATIC attributes and the element's part indices are both still
|
|
605
|
+
// visible. This is the only point in compilation with that whole view,
|
|
606
|
+
// and it runs once per template, so the record is a constant rather
|
|
607
|
+
// than runtime memory that can drift out of step with the DOM. It is
|
|
608
|
+
// also the only place a submitter's own `name` is still legible: by
|
|
609
|
+
// reconcile time the renderer has written the identity over it.
|
|
610
|
+
if (el.localName === 'form' || el.localName === 'button' || el.localName === 'input') {
|
|
611
|
+
const rec = buildFormActionRecord(el, onEl, parts);
|
|
612
|
+
if (rec) formActions.push(rec);
|
|
613
|
+
}
|
|
490
614
|
for (const a of toRemove) el.removeAttribute(a);
|
|
491
615
|
visit(child);
|
|
492
616
|
}
|
|
@@ -494,6 +618,307 @@ function assignPaths(root, parts) {
|
|
|
494
618
|
}
|
|
495
619
|
}
|
|
496
620
|
visit(root);
|
|
621
|
+
return formActions.length ? formActions : null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Build the compile-time record for one `<form>` carrying an `action` hole, or
|
|
626
|
+
* one submitter carrying a `formaction` hole.
|
|
627
|
+
*
|
|
628
|
+
* A CANDIDATE, not a decision: `<form action=${maybeString}>` compiles exactly
|
|
629
|
+
* like a bound one, so whether the form is really bound is a runtime fact the
|
|
630
|
+
* reconcile decides from the value. Everything recorded here is a property of
|
|
631
|
+
* the TEMPLATE, which is why it cannot go stale the way remembering "did the
|
|
632
|
+
* bind supply this attribute" at runtime did.
|
|
633
|
+
*
|
|
634
|
+
* `encoding` folds into `enctype` for a PROPERTY binding only: it is a legacy
|
|
635
|
+
* IDL alias, so `.encoding=` writes the enctype content attribute. As a content
|
|
636
|
+
* attribute `encoding=` is inert (a browser's `form.encoding` reads back
|
|
637
|
+
* `enctype`), and SSR ignores it, so folding the attribute spelling too would
|
|
638
|
+
* make the client honour a value the server never saw.
|
|
639
|
+
*
|
|
640
|
+
* The submitter fields (`authoredName` / `authoredValue` / `authoredForm`) are
|
|
641
|
+
* read off the COMPILED template rather than the live element, for the same
|
|
642
|
+
* reason: by reconcile time the renderer has written its own `name` / `value`
|
|
643
|
+
* onto a bound submitter, so the live element can no longer say which of them
|
|
644
|
+
* the author wrote. A hole-provided `name=${n}` leaves no attribute on the
|
|
645
|
+
* compiled template at all, which is why `nameParts` is tracked separately.
|
|
646
|
+
*
|
|
647
|
+
* @param {Element} el
|
|
648
|
+
* @param {{ idx: number, kind: string, name: string }[]} onEl parts bound to `el`
|
|
649
|
+
* @param {PartDescriptor[]} parts
|
|
650
|
+
* @returns {FormActionRecord | null}
|
|
651
|
+
*/
|
|
652
|
+
function buildFormActionRecord(el, onEl, parts) {
|
|
653
|
+
const isForm = el.localName === 'form';
|
|
654
|
+
const targetAttr = isForm ? 'action' : 'formaction';
|
|
655
|
+
const actionParts = onEl.filter((p) => p.kind === 'attr' && p.name.toLowerCase() === targetAttr);
|
|
656
|
+
if (!actionParts.length) return null;
|
|
657
|
+
|
|
658
|
+
/** @type {FormAttrPart[]} */
|
|
659
|
+
const methodParts = [];
|
|
660
|
+
/** @type {FormAttrPart[]} */
|
|
661
|
+
const enctypeParts = [];
|
|
662
|
+
/** Prop bindings that cannot converge with SSR; refused when actually bound. */
|
|
663
|
+
const propAttrs = [];
|
|
664
|
+
// `name` holes on a submitter, recorded WITH their kind. Whether one occupies
|
|
665
|
+
// the identity's channel is not a property of the hole alone: SSR emits
|
|
666
|
+
// `name=""` for an attribute hole whatever it resolved to, but emits nothing
|
|
667
|
+
// at all for a FALSY boolean hole and nothing for an `@name` listener. Asking
|
|
668
|
+
// only "is there a part called name" therefore refused templates SSR renders
|
|
669
|
+
// happily, which is the render-on-the-server-throw-on-hydration direction.
|
|
670
|
+
// The kinds travel with the record and `reconcileFormActions` resolves them.
|
|
671
|
+
const nameParts = isForm ? [] : onEl
|
|
672
|
+
.filter((p) => p.name.toLowerCase() === 'name'
|
|
673
|
+
&& (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
|
|
674
|
+
.map((p) => ({ i: p.idx, kind: p.kind }));
|
|
675
|
+
const authoredName = !isForm && el.hasAttribute('name');
|
|
676
|
+
const authoredForm = !isForm && el.hasAttribute('form');
|
|
677
|
+
const authoredValue = !isForm && el.hasAttribute('value');
|
|
678
|
+
// `value` holes, recorded with their kind for the same reason as `name`: a
|
|
679
|
+
// FALSY boolean hole emits nothing at SSR, so counting the part's mere
|
|
680
|
+
// presence refused a template the server renders happily.
|
|
681
|
+
const valueParts = isForm ? [] : onEl
|
|
682
|
+
.filter((p) => p.name.toLowerCase() === 'value'
|
|
683
|
+
&& (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
|
|
684
|
+
.map((p) => ({ i: p.idx, kind: p.kind }));
|
|
685
|
+
|
|
686
|
+
for (const p of onEl) {
|
|
687
|
+
const name = String(p.name).toLowerCase();
|
|
688
|
+
if (p.kind === 'prop') {
|
|
689
|
+
// On a FORM, the reflected attributes that decide submittability. On a
|
|
690
|
+
// SUBMITTER, the ones that carry the identity or override the submission
|
|
691
|
+
// (#1207). Either way a `.prop` is dropped at SSR and written to the
|
|
692
|
+
// attribute in the browser, so it can never converge.
|
|
693
|
+
if (isForm) {
|
|
694
|
+
if (name === 'method' || name === 'enctype' || name === 'encoding') propAttrs.push(p.name);
|
|
695
|
+
} else if (isSubmitterReflectedProp(name)) {
|
|
696
|
+
propAttrs.push(p.name);
|
|
697
|
+
}
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (name !== 'method' && name !== 'enctype') continue;
|
|
701
|
+
if (p.kind !== 'attr' && p.kind !== 'attr-mixed' && p.kind !== 'bool') continue;
|
|
702
|
+
const d = /** @type any */ (parts[p.idx]);
|
|
703
|
+
/** @type {FormAttrPart} */
|
|
704
|
+
const entry = { i: p.idx, kind: p.kind };
|
|
705
|
+
// A mixed attribute's value is `statics[0] + v0 + statics[1] + ...`, so the
|
|
706
|
+
// pieces have to travel with the record; reading the anchor's value alone
|
|
707
|
+
// is right only when the statics are empty.
|
|
708
|
+
if (p.kind === 'attr-mixed') { entry.statics = d.statics || []; entry.group = d.group || []; }
|
|
709
|
+
(name === 'method' ? methodParts : enctypeParts).push(entry);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return {
|
|
713
|
+
isForm,
|
|
714
|
+
tag: el.localName,
|
|
715
|
+
// EVERY action hole, not just the first. SSR refuses two holes whenever
|
|
716
|
+
// ANY of them resolves to a bound action, so recording only the first would
|
|
717
|
+
// let `<form action=${'/legacy'} action=${boundFn}>` slip through the
|
|
718
|
+
// client's release path while SSR throws on the same template.
|
|
719
|
+
actionIdxs: actionParts.map((p) => p.idx),
|
|
720
|
+
duplicateAction: actionParts.length > 1,
|
|
721
|
+
// A static `action="..."` / `formaction="..."` surviving alongside the hole.
|
|
722
|
+
// The compiled template holds the hole as a sentinel attribute, so anything
|
|
723
|
+
// read back under the real name here is the author's own second one.
|
|
724
|
+
staticAction: el.getAttribute(targetAttr) != null,
|
|
725
|
+
authoredName,
|
|
726
|
+
authoredValue,
|
|
727
|
+
authoredForm,
|
|
728
|
+
propAttrs,
|
|
729
|
+
nameParts,
|
|
730
|
+
valueParts,
|
|
731
|
+
staticMethod: el.getAttribute('method'),
|
|
732
|
+
staticEnctype: el.getAttribute('enctype'),
|
|
733
|
+
methodParts,
|
|
734
|
+
enctypeParts,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Converge every candidate bound form and submitter in this template, after all
|
|
740
|
+
* of its parts have committed. A no-op for the overwhelming majority of
|
|
741
|
+
* templates, which carry no `<form action=${...}>` at all and therefore no
|
|
742
|
+
* record.
|
|
743
|
+
*
|
|
744
|
+
* FORMS FIRST, submitters second, regardless of the order the records were
|
|
745
|
+
* collected in. A submitter asks whether its enclosing form is bound, and the
|
|
746
|
+
* cheapest true answer is the identity field the form's own reconcile just
|
|
747
|
+
* inserted. Document order gets this right for a form and a button in the SAME
|
|
748
|
+
* template, but not for a `<form>` whose submitter arrives through a nested
|
|
749
|
+
* template, so the ordering is made explicit rather than relied upon.
|
|
750
|
+
*
|
|
751
|
+
* @param {FormActionRecord[] | null} formActions
|
|
752
|
+
* @param {BoundPart[]} bound
|
|
753
|
+
* @param {unknown[]} values
|
|
754
|
+
*/
|
|
755
|
+
function reconcileFormActions(formActions, bound, values) {
|
|
756
|
+
if (!formActions) return;
|
|
757
|
+
/** @type {HTMLFormElement[]} */
|
|
758
|
+
const boundForms = [];
|
|
759
|
+
for (const pass of [true, false]) {
|
|
760
|
+
for (const rec of formActions) {
|
|
761
|
+
if (rec.isForm !== pass) continue;
|
|
762
|
+
// With more than one action hole, the BOUND one decides, whichever
|
|
763
|
+
// position it is written in. Picking `actionIdxs[0]` blindly would send
|
|
764
|
+
// `<form action=${'/url'} action=${boundFn}>` down the release path and
|
|
765
|
+
// ship the broken form SSR refuses outright.
|
|
766
|
+
let idx = rec.actionIdxs[0];
|
|
767
|
+
const targetAttr = rec.isForm ? 'action' : 'formaction';
|
|
768
|
+
for (const i of rec.actionIdxs) {
|
|
769
|
+
if (isBoundFormAction(resolveHoleValue(values[i]), targetAttr, rec.tag)) { idx = i; break; }
|
|
770
|
+
}
|
|
771
|
+
const part = bound[idx];
|
|
772
|
+
if (!part || !part.el) continue;
|
|
773
|
+
const val = resolveHoleValue(values[idx]);
|
|
774
|
+
if (rec.isForm) {
|
|
775
|
+
reconcileFormAction(
|
|
776
|
+
/** @type any */ (part.el),
|
|
777
|
+
val,
|
|
778
|
+
effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
|
|
779
|
+
effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
|
|
780
|
+
rec,
|
|
781
|
+
);
|
|
782
|
+
if (typeof val === 'function' && formActionId(val)) {
|
|
783
|
+
boundForms.push(/** @type any */ (part.el));
|
|
784
|
+
}
|
|
785
|
+
} else {
|
|
786
|
+
// What SSR would have emitted for this pass. An attribute hole always
|
|
787
|
+
// emits (even `name=${null}`, as `name=""`); a boolean hole emits only
|
|
788
|
+
// when truthy. Both identity channels ask the same question, through
|
|
789
|
+
// one predicate, so they cannot drift apart again.
|
|
790
|
+
const emits = (parts) => parts.some((np) => (np.kind === 'bool'
|
|
791
|
+
? !!resolveHoleValue(values[np.i])
|
|
792
|
+
: true));
|
|
793
|
+
reconcileSubmitterAction(
|
|
794
|
+
/** @type any */ (part.el), val, rec, emits(rec.nameParts), emits(rec.valueParts),
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// Part B (#1207), last, because it reads the DOM state every reconcile above
|
|
800
|
+
// has finished writing. See `assertBoundFormSubmitters`.
|
|
801
|
+
for (const form of boundForms) assertBoundFormSubmitters(form);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Remove the identity channel previously injected into a submitter.
|
|
806
|
+
*
|
|
807
|
+
* Only ours is removed. `submitterActionBindings` remembers the elements this
|
|
808
|
+
* renderer stamped, and the `name` check covers an SSR'd button meeting its
|
|
809
|
+
* template for the first time on hydration. A button that never carried a
|
|
810
|
+
* binding is left alone, so an author's own `name` / `value` survives a
|
|
811
|
+
* re-render that happens to pass a non-action value.
|
|
812
|
+
*
|
|
813
|
+
* @param {Element} el
|
|
814
|
+
*/
|
|
815
|
+
function releaseSubmitterAction(el) {
|
|
816
|
+
const injected = submitterActionBindings.has(el) || el.getAttribute('name') === FORM_ACTION_FIELD;
|
|
817
|
+
if (!injected) return;
|
|
818
|
+
if (el.getAttribute('name') === FORM_ACTION_FIELD) el.removeAttribute('name');
|
|
819
|
+
el.removeAttribute('value');
|
|
820
|
+
submitterActionBindings.delete(el);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Find the enclosing `<form>` by walking parents.
|
|
825
|
+
*
|
|
826
|
+
* Deliberately a parent walk rather than `closest('form')`: the element may
|
|
827
|
+
* still be inside a detached DocumentFragment when this runs, and the walk
|
|
828
|
+
* gives the same answer there for a form and a button in the same template.
|
|
829
|
+
* Returns null when the fragment's root is reached, which is the honest answer
|
|
830
|
+
* for a nested template whose form lives in the parent.
|
|
831
|
+
*
|
|
832
|
+
* @param {Element} el
|
|
833
|
+
* @returns {HTMLFormElement | null}
|
|
834
|
+
*/
|
|
835
|
+
function enclosingForm(el) {
|
|
836
|
+
let node = el.parentNode;
|
|
837
|
+
while (node) {
|
|
838
|
+
if (node.nodeType === 1 && /** @type {Element} */ (node).localName === 'form') {
|
|
839
|
+
return /** @type {HTMLFormElement} */ (node);
|
|
840
|
+
}
|
|
841
|
+
node = node.parentNode;
|
|
842
|
+
}
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Converge a live submitter on what SSR emitted for `<button formaction=${fn}>`
|
|
848
|
+
* (#1207): the `formaction` attribute gone, and the identity in the button's
|
|
849
|
+
* own `name` / `value` pair.
|
|
850
|
+
*
|
|
851
|
+
* The enclosing-form check is BEST EFFORT, and that is a deliberate asymmetry
|
|
852
|
+
* with SSR rather than a gap. SSR reads a linear byte stream and always knows
|
|
853
|
+
* whether the open form was bound, so it refuses an unbound one outright. The
|
|
854
|
+
* client may be reconciling a fragment that is not in the tree yet (a submitter
|
|
855
|
+
* inside a `repeat()` or an array item is built detached, by design, and its
|
|
856
|
+
* form lives in the parent template), where the question has no answer. Asking
|
|
857
|
+
* anyway and throwing on "no" would refuse the single most ordinary shape this
|
|
858
|
+
* feature exists for, a per-row button in a list, on a page SSR renders
|
|
859
|
+
* perfectly. So an UNRESOLVED form skips the assertion and the binding is
|
|
860
|
+
* applied: the server is the renderer that sees every page, and a genuinely
|
|
861
|
+
* unbound form is still refused there, loudly, before anything ships.
|
|
862
|
+
*
|
|
863
|
+
* @param {Element} el
|
|
864
|
+
* @param {unknown} value
|
|
865
|
+
* @param {FormActionRecord} rec
|
|
866
|
+
* @param {boolean} emitsName whether SSR would emit a `name` attribute this pass
|
|
867
|
+
* @param {boolean} emitsValue whether SSR would emit a `value` attribute this pass
|
|
868
|
+
*/
|
|
869
|
+
function reconcileSubmitterAction(el, value, rec, emitsName, emitsValue) {
|
|
870
|
+
const id = typeof value === 'function' ? formActionId(value) : null;
|
|
871
|
+
if (!id) {
|
|
872
|
+
// A function that was MEANT as an action still refuses, so a button never
|
|
873
|
+
// silently submits the form's action instead of its own.
|
|
874
|
+
if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
|
|
875
|
+
releaseSubmitterAction(el);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// Template-shaped refusals first, from the compiled record, because the live
|
|
879
|
+
// element may already carry this renderer's own `name` / `value`.
|
|
880
|
+
assertSingleSubmitterAction(rec.duplicateAction, el.localName);
|
|
881
|
+
assertConvergentSubmitter(rec.propAttrs, el.localName, true);
|
|
882
|
+
if (rec.staticAction) assertSubmitterHasNoStaticFormAction(el.localName);
|
|
883
|
+
if (rec.authoredValue || emitsValue) assertSubmitterHasNoValue(el.localName);
|
|
884
|
+
if (rec.authoredName || emitsName) {
|
|
885
|
+
// Judged on the PART, not on what it resolved to this pass. `name=${null}`
|
|
886
|
+
// leaves no attribute here while SSR emits `name=""` beside the identity,
|
|
887
|
+
// so reading the live value back returned '' and waved through a template
|
|
888
|
+
// SSR refuses. The template supplying a `name` channel at all is the
|
|
889
|
+
// conflict, whatever today's value happens to be.
|
|
890
|
+
assertSubmitterHasNoName(el.getAttribute('name') || FORM_ACTION_FIELD, el.localName, false);
|
|
891
|
+
}
|
|
892
|
+
if (rec.authoredForm || el.hasAttribute('form')) assertSubmitterHasNoFormAttribute(el.localName);
|
|
893
|
+
assertSubmitterType(el.localName, el.getAttribute('type'));
|
|
894
|
+
|
|
895
|
+
const form = enclosingForm(el);
|
|
896
|
+
// Asked ONCE per element. The answer depends on whether this element happened
|
|
897
|
+
// to be in the tree when it reconciled, which differs between a first render
|
|
898
|
+
// (fragment still detached, so `enclosingForm` finds nothing and the check is
|
|
899
|
+
// skipped) and any later update (attached, so it resolves). Re-asking made the
|
|
900
|
+
// SAME template with the SAME values bind on first paint and then throw on an
|
|
901
|
+
// arbitrary later re-render, which is far worse to diagnose than a refusal at
|
|
902
|
+
// first paint. Skipping for an element already stamped can only REMOVE a
|
|
903
|
+
// throw, never add one, so it cannot refuse anything that used to render.
|
|
904
|
+
if (form && !submitterActionBindings.has(el)) {
|
|
905
|
+
// `formActionCandidates` covers the pass where the form's own action hole
|
|
906
|
+
// has committed but its reconcile has not run yet; the identity field is
|
|
907
|
+
// the settled answer, including for an SSR'd form on first hydration.
|
|
908
|
+
const isFormBound = !!(form.querySelector(`input[name="${FORM_ACTION_FIELD}"]`)
|
|
909
|
+
|| formActionCandidates.has(form));
|
|
910
|
+
assertSubmitterFormIsBound(isFormBound, el.localName);
|
|
911
|
+
}
|
|
912
|
+
assertSubmitterSubmission(
|
|
913
|
+
el.localName,
|
|
914
|
+
el.getAttribute('formmethod'),
|
|
915
|
+
el.getAttribute('formenctype'),
|
|
916
|
+
{ bound: true },
|
|
917
|
+
);
|
|
918
|
+
el.removeAttribute('formaction');
|
|
919
|
+
el.setAttribute('name', FORM_ACTION_FIELD);
|
|
920
|
+
el.setAttribute('value', id);
|
|
921
|
+
submitterActionBindings.set(el, id);
|
|
497
922
|
}
|
|
498
923
|
|
|
499
924
|
/* ================================================================
|
|
@@ -505,7 +930,7 @@ function assignPaths(root, parts) {
|
|
|
505
930
|
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
506
931
|
*/
|
|
507
932
|
function createInstance(tr, container) {
|
|
508
|
-
const { templateEl, parts } = compile(tr);
|
|
933
|
+
const { templateEl, parts, formActions } = compile(tr);
|
|
509
934
|
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
510
935
|
|
|
511
936
|
// Bookend markers bound the instance so we can tear it down cleanly.
|
|
@@ -518,6 +943,7 @@ function createInstance(tr, container) {
|
|
|
518
943
|
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
519
944
|
lastValues.push(tr.values[i]);
|
|
520
945
|
}
|
|
946
|
+
reconcileFormActions(formActions, bound, tr.values);
|
|
521
947
|
|
|
522
948
|
/** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
|
|
523
949
|
|
|
@@ -587,6 +1013,13 @@ function bindPart(p, root) {
|
|
|
587
1013
|
throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
|
|
588
1014
|
}
|
|
589
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* Sentinel parked in `lastValues` for a hole whose commit threw, so the next
|
|
1018
|
+
* render cannot mistake the un-advanced entry for "already applied". Never
|
|
1019
|
+
* equal (by `Object.is`) to anything an author can pass through a template.
|
|
1020
|
+
*/
|
|
1021
|
+
const COMMIT_FAILED = Symbol('webjs.commitFailed');
|
|
1022
|
+
|
|
590
1023
|
/**
|
|
591
1024
|
* @param {TemplateInstance} inst
|
|
592
1025
|
* @param {unknown[]} values
|
|
@@ -602,13 +1035,35 @@ function updateInstance(inst, values) {
|
|
|
602
1035
|
// anchor hole's. Without this, a change confined to a non-anchor hole is
|
|
603
1036
|
// dropped (the attribute goes stale).
|
|
604
1037
|
const anchor = /** @type any */ (bp).mixedAnchor;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1038
|
+
try {
|
|
1039
|
+
if (bp.kind === 'noop' && anchor != null) {
|
|
1040
|
+
applyPart(inst.bound[anchor], values[anchor], inst.lastValues[anchor], values);
|
|
1041
|
+
} else {
|
|
1042
|
+
applyPart(bp, next, inst.lastValues[i], values);
|
|
1043
|
+
}
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
// A commit that throws leaves `lastValues` for THIS hole un-advanced,
|
|
1046
|
+
// still holding the value from before the throw. That is almost always
|
|
1047
|
+
// the value the recovering render supplies, so the `Object.is` skip at
|
|
1048
|
+
// the top of this loop would skip the hole FOREVER. Harmless for an
|
|
1049
|
+
// attribute (its commit stringifies before touching the DOM, so nothing
|
|
1050
|
+
// changed), and permanently destructive for a child position, whose
|
|
1051
|
+
// commit tears the old content down BEFORE the step that throws: the
|
|
1052
|
+
// region is left empty and never re-rendered.
|
|
1053
|
+
//
|
|
1054
|
+
// Poison the entry with a sentinel no author value can ever be, so the
|
|
1055
|
+
// next render always re-applies this hole. The value is deliberately
|
|
1056
|
+
// NOT advanced to `next` either, since `next` was never committed.
|
|
1057
|
+
inst.lastValues[i] = COMMIT_FAILED;
|
|
1058
|
+
if (bp.kind === 'noop' && anchor != null) inst.lastValues[anchor] = COMMIT_FAILED;
|
|
1059
|
+
throw err;
|
|
609
1060
|
}
|
|
610
1061
|
inst.lastValues[i] = next;
|
|
611
1062
|
}
|
|
1063
|
+
// Unconditional, and NOT inside the loop above: a form's correctness depends
|
|
1064
|
+
// on holes other than its own, and the `Object.is` skip means the action hole
|
|
1065
|
+
// may not have been re-applied at all this pass.
|
|
1066
|
+
reconcileFormActions(templateCache.get(inst.strings)?.formActions ?? null, inst.bound, values);
|
|
612
1067
|
}
|
|
613
1068
|
|
|
614
1069
|
/**
|
|
@@ -623,12 +1078,19 @@ function clearInstance(inst, container) {
|
|
|
623
1078
|
for (const p of inst.bound) {
|
|
624
1079
|
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
625
1080
|
if (p.kind === 'element') {
|
|
1081
|
+
// Guarded for the same reason as the sibling in `disposeInstance`, and
|
|
1082
|
+
// the stakes are higher here: this is the container-level teardown
|
|
1083
|
+
// `render()` runs before a template swap, so a throw skips the rest of
|
|
1084
|
+
// this loop AND the `replaceChildren()` below, leaving the old DOM in
|
|
1085
|
+
// place with `host[INSTANCE]` never reassigned. Since `lastTarget` is
|
|
1086
|
+
// cleared only after the write, every later swap of that container
|
|
1087
|
+
// then throws at the same part, permanently.
|
|
626
1088
|
const prev = /** @type any */ (p).lastTarget;
|
|
627
1089
|
if (prev) {
|
|
628
1090
|
if (typeof prev === 'function') {
|
|
629
1091
|
try { prev(undefined); } catch { /* swallow */ }
|
|
630
1092
|
} else if (typeof prev === 'object') {
|
|
631
|
-
prev.value = undefined;
|
|
1093
|
+
try { prev.value = undefined; } catch { /* swallow */ }
|
|
632
1094
|
}
|
|
633
1095
|
/** @type any */ (p).lastTarget = undefined;
|
|
634
1096
|
/** @type any */ (p).__lastEl = undefined;
|
|
@@ -649,6 +1111,54 @@ function clearInstance(inst, container) {
|
|
|
649
1111
|
* Part application
|
|
650
1112
|
* ================================================================ */
|
|
651
1113
|
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Resolve what SSR would have emitted for one attribute of a candidate form.
|
|
1117
|
+
*
|
|
1118
|
+
* The per-kind rules mirror `render-server.js` exactly, which is the whole
|
|
1119
|
+
* point: a boolean hole emits nothing when falsy (`if (val) out += name+'=""'`)
|
|
1120
|
+
* while an attribute hole emits an EMPTY value for null (`String(val ?? '')`).
|
|
1121
|
+
* Those two produce the same DOM and opposite verdicts, so only the template
|
|
1122
|
+
* can tell them apart.
|
|
1123
|
+
*
|
|
1124
|
+
* @param {FormAttrPart[]} attrParts
|
|
1125
|
+
* @param {string | null} staticValue
|
|
1126
|
+
* @param {unknown[]} values
|
|
1127
|
+
* @returns {string | typeof ABSENT}
|
|
1128
|
+
*/
|
|
1129
|
+
function effectiveFormAttr(attrParts, staticValue, values) {
|
|
1130
|
+
for (const p of attrParts) {
|
|
1131
|
+
if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT;
|
|
1132
|
+
if (p.kind === 'attr') {
|
|
1133
|
+
const v = resolveHoleValue(values[p.i]);
|
|
1134
|
+
return v == null ? '' : String(v);
|
|
1135
|
+
}
|
|
1136
|
+
// A mixed attribute is the concatenation of its static pieces and EVERY one
|
|
1137
|
+
// of its holes, so the anchor's own value is only part of the answer.
|
|
1138
|
+
const statics = p.statics || [];
|
|
1139
|
+
const group = p.group || [];
|
|
1140
|
+
let out = statics[0] || '';
|
|
1141
|
+
for (let j = 0; j < group.length; j++) {
|
|
1142
|
+
out += String(resolveHoleValue(values[group[j]]) ?? '');
|
|
1143
|
+
out += statics[j + 1] || '';
|
|
1144
|
+
}
|
|
1145
|
+
return out;
|
|
1146
|
+
}
|
|
1147
|
+
return staticValue == null ? ABSENT : staticValue;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Unwrap a hole's value the same way `applyPart` does, so the reconcile judges
|
|
1152
|
+
* what was actually committed. `live()` in particular wraps its value, and
|
|
1153
|
+
* reading the wrapper would make a bound action look like a plain object.
|
|
1154
|
+
*
|
|
1155
|
+
* @param {unknown} v
|
|
1156
|
+
* @returns {unknown}
|
|
1157
|
+
*/
|
|
1158
|
+
function resolveHoleValue(v) {
|
|
1159
|
+
return isLive(v) ? /** @type any */ (v).value : v;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
652
1162
|
/**
|
|
653
1163
|
* @param {BoundPart} part
|
|
654
1164
|
* @param {unknown} value
|
|
@@ -670,18 +1180,71 @@ function applyPart(part, value, _prev, allValues) {
|
|
|
670
1180
|
applyChild(part, value);
|
|
671
1181
|
break;
|
|
672
1182
|
case 'attr': {
|
|
1183
|
+
// #1207: note a form whose action hole holds a function, so a submitter
|
|
1184
|
+
// reconciling BEFORE that form's own reconcile can still tell the form is
|
|
1185
|
+
// going to be bound. Recorded at commit time because that is the only
|
|
1186
|
+
// moment the hole's value is in hand for a form the submitter does not
|
|
1187
|
+
// own. Cleared on a non-function so a form that stops being bound does
|
|
1188
|
+
// not keep vouching for its buttons.
|
|
1189
|
+
if (part.el.localName === 'form' && part.name.toLowerCase() === 'action') {
|
|
1190
|
+
const candidate = resolveHoleValue(value);
|
|
1191
|
+
if (typeof candidate === 'function') formActionCandidates.set(part.el, candidate);
|
|
1192
|
+
else formActionCandidates.delete(part.el);
|
|
1193
|
+
}
|
|
673
1194
|
if (value == null || value === false) part.el.removeAttribute(part.name);
|
|
674
|
-
else
|
|
1195
|
+
else if (isBoundFormAction(value, part.name, part.el.localName)) {
|
|
1196
|
+
// #1155: the ONE supported form-action binding, applied to the live
|
|
1197
|
+
// form exactly as SSR wrote it. A component that ships re-renders its
|
|
1198
|
+
// whole template on hydration, so without this the SSR'd hidden field
|
|
1199
|
+
// would be replaced by an `action` attribute holding a stringified
|
|
1200
|
+
// function, and the form would post to a garbage url.
|
|
1201
|
+
//
|
|
1202
|
+
// Nothing happens here. The whole decision (identity, the submit
|
|
1203
|
+
// attributes, the hidden field) is made at the end of the pass by
|
|
1204
|
+
// `reconcileFormAction`, because it depends on holes that have not
|
|
1205
|
+
// committed yet when this one does.
|
|
1206
|
+
} else {
|
|
1207
|
+
// #1154: refuse to stringify a function into action=/formaction=
|
|
1208
|
+
// (mirrors the SSR guard, so a client re-render cannot write a
|
|
1209
|
+
// server action's source into the live DOM).
|
|
1210
|
+
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
1211
|
+
part.el.setAttribute(part.name, String(value));
|
|
1212
|
+
}
|
|
675
1213
|
break;
|
|
676
1214
|
}
|
|
677
1215
|
case 'prop':
|
|
1216
|
+
// `.action=${fn}` is a leak too, not just the attribute form, but only
|
|
1217
|
+
// where the property is a REFLECTED IDL attribute: assigning a function
|
|
1218
|
+
// to `form.action` (or `.formAction` on a button/input) stringifies it
|
|
1219
|
+
// into that element's own content attribute in a real browser. On any
|
|
1220
|
+
// other native tag it is a plain expando that reflects nothing, and on a
|
|
1221
|
+
// custom element it is an author-defined property, so a function is
|
|
1222
|
+
// legitimate in both. The helper owns that distinction.
|
|
1223
|
+
//
|
|
1224
|
+
// Not covered here, because it is not a commit: a custom element's prop
|
|
1225
|
+
// declared `reflect: true` reflects from its own setter. That path used
|
|
1226
|
+
// to write String(value) and leak the source. #1169 made it remove the
|
|
1227
|
+
// attribute instead (an array carrying one included, via the same
|
|
1228
|
+
// `carriesFunction` predicate this file's guard uses), so it is guarded
|
|
1229
|
+
// at the setter rather than here. One carve-out stays the author's call:
|
|
1230
|
+
// a prop supplying its own `converter.toAttribute` runs that converter
|
|
1231
|
+
// first and is left alone, so it still writes whatever the author
|
|
1232
|
+
// returns for a function.
|
|
1233
|
+
assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
|
|
678
1234
|
/** @type any */ (part.el)[part.name] = value;
|
|
679
1235
|
break;
|
|
680
1236
|
case 'bool':
|
|
1237
|
+
// #1154: never leaked (a boolean binding stringifies nothing), but
|
|
1238
|
+
// `?action=${fn}` is meaningless in every case, and refusing it keeps
|
|
1239
|
+
// this renderer agreeing with both SSR machines.
|
|
1240
|
+
assertNotFunctionActionAttr(value, part.name, part.el.localName);
|
|
681
1241
|
if (value) part.el.setAttribute(part.name, '');
|
|
682
1242
|
else part.el.removeAttribute(part.name);
|
|
683
1243
|
break;
|
|
684
1244
|
case 'event':
|
|
1245
|
+
// NOT guarded, deliberately. An event binding never stringifies its
|
|
1246
|
+
// value, and a function is the legitimate thing to pass one, so
|
|
1247
|
+
// `<my-el @action=${handler}>` has to keep working.
|
|
685
1248
|
part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
|
|
686
1249
|
break;
|
|
687
1250
|
case 'element':
|
|
@@ -692,7 +1255,10 @@ function applyPart(part, value, _prev, allValues) {
|
|
|
692
1255
|
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
|
|
693
1256
|
let val = mp.statics[0];
|
|
694
1257
|
for (let j = 0; j < mp.group.length; j++) {
|
|
695
|
-
|
|
1258
|
+
const piece = allValues ? allValues[mp.group[j]] : value;
|
|
1259
|
+
// #1154: same function guard for each piece of a mixed attribute.
|
|
1260
|
+
assertNotFunctionActionAttr(piece, part.name, part.el.localName);
|
|
1261
|
+
val += String(piece ?? '');
|
|
696
1262
|
val += mp.statics[j + 1] || '';
|
|
697
1263
|
}
|
|
698
1264
|
part.el.setAttribute(part.name, val);
|
|
@@ -955,10 +1521,17 @@ function applyChildInnerRaw(part, value) {
|
|
|
955
1521
|
: Object.is(prevDeps, nextDeps);
|
|
956
1522
|
if (equal) return;
|
|
957
1523
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1524
|
+
// Snapshot the deps BEFORE running `fn` (so a fn that mutates the array
|
|
1525
|
+
// it was handed cannot rewrite what we record), but RECORD them only
|
|
1526
|
+
// after the commit succeeds. Recording first meant a throw part-way
|
|
1527
|
+
// through the commit left the region torn down with the NEW deps already
|
|
1528
|
+
// stored, so every later render with the same deps hit the `equal`
|
|
1529
|
+
// short-circuit above and skipped the region forever. Renders are always
|
|
1530
|
+
// microtask-scheduled (`_scheduleUpdate`), so `fn()` cannot re-enter this
|
|
1531
|
+
// part synchronously and see the stale deps.
|
|
1532
|
+
const depsSnapshot = Array.isArray(nextDeps) ? nextDeps.slice() : nextDeps;
|
|
961
1533
|
applyChildInner(part, v.fn());
|
|
1534
|
+
/** @type any */ (part).__guardDeps = depsSnapshot;
|
|
962
1535
|
return;
|
|
963
1536
|
}
|
|
964
1537
|
|
|
@@ -1091,7 +1664,7 @@ function applyChildInnerRaw(part, value) {
|
|
|
1091
1664
|
|
|
1092
1665
|
if (isTemplate(value)) {
|
|
1093
1666
|
const tr = /** @type any */ (value);
|
|
1094
|
-
const { templateEl, parts } = compile(tr);
|
|
1667
|
+
const { templateEl, parts, formActions } = compile(tr);
|
|
1095
1668
|
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1096
1669
|
const startNode = document.createComment(`${MARKER}s`);
|
|
1097
1670
|
const endNode = document.createComment(`${MARKER}e`);
|
|
@@ -1101,6 +1674,7 @@ function applyChildInnerRaw(part, value) {
|
|
|
1101
1674
|
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1102
1675
|
lastValues.push(tr.values[i]);
|
|
1103
1676
|
}
|
|
1677
|
+
reconcileFormActions(formActions, bound, tr.values);
|
|
1104
1678
|
const nodes = [startNode, ...frag.childNodes, endNode];
|
|
1105
1679
|
marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
|
|
1106
1680
|
// Slot parts in this nested template need their one-shot apply just
|
|
@@ -1150,7 +1724,7 @@ function removeBetween(start, end) {
|
|
|
1150
1724
|
* @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
|
|
1151
1725
|
*/
|
|
1152
1726
|
function buildDetached(tr) {
|
|
1153
|
-
const { templateEl, parts } = compile(tr);
|
|
1727
|
+
const { templateEl, parts, formActions } = compile(tr);
|
|
1154
1728
|
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1155
1729
|
const startNode = document.createComment(`${MARKER}s`);
|
|
1156
1730
|
const endNode = document.createComment(`${MARKER}e`);
|
|
@@ -1160,6 +1734,7 @@ function buildDetached(tr) {
|
|
|
1160
1734
|
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
1161
1735
|
lastValues.push(tr.values[i]);
|
|
1162
1736
|
}
|
|
1737
|
+
reconcileFormActions(formActions, bound, tr.values);
|
|
1163
1738
|
// Slot parts need their one-shot apply exactly like createInstance and the
|
|
1164
1739
|
// nested-template path. The fragment is still detached here, so the
|
|
1165
1740
|
// slot-part's own deferred finalize (a one-microtask retry when the parent
|
|
@@ -1188,12 +1763,33 @@ function disposeInstance(inst) {
|
|
|
1188
1763
|
// Unbind any active ref so the user observes the element being
|
|
1189
1764
|
// removed (callback receives undefined / Ref.value cleared).
|
|
1190
1765
|
// Mirrors lit-html's cleanup-on-disconnect for element parts.
|
|
1766
|
+
//
|
|
1767
|
+
// BOTH branches swallow, and lit is not the reason: lit's ref directive
|
|
1768
|
+
// guards neither, so a throw there propagates. The reason is that a
|
|
1769
|
+
// teardown has to be TOTAL. `lastTarget` is cleared only AFTER these
|
|
1770
|
+
// writes, so a throw leaves the part still pointing at the ref and
|
|
1771
|
+
// every later teardown of the same instance throws at the same line
|
|
1772
|
+
// forever. It also aborts the rest of this loop, so the remaining
|
|
1773
|
+
// parts keep their listeners and their refs bound. A teardown has no
|
|
1774
|
+
// retry either (a commit has the COMMIT_FAILED sentinel and a next
|
|
1775
|
+
// render; this does not), so there is nothing a propagated error could
|
|
1776
|
+
// usefully repair.
|
|
1777
|
+
//
|
|
1778
|
+
// The object branch is the one this adds. The callback branch was
|
|
1779
|
+
// already guarded here AND on the commit path (`applyElement` wraps
|
|
1780
|
+
// every `nextTarget(...)` / `prevTarget(undefined)` call), so a
|
|
1781
|
+
// throwing ref CALLBACK has always been swallowed everywhere. What was
|
|
1782
|
+
// inconsistent is the object ref, guarded on neither. This makes the
|
|
1783
|
+
// two agree on TEARDOWN, which is where the totality argument bites.
|
|
1784
|
+
// It does NOT touch the commit path, so `applyElement`'s object-ref
|
|
1785
|
+
// writes still propagate to the component boundary, which has a route
|
|
1786
|
+
// for the error and a next render to repair it.
|
|
1191
1787
|
const prev = /** @type any */ (p).lastTarget;
|
|
1192
1788
|
if (prev) {
|
|
1193
1789
|
if (typeof prev === 'function') {
|
|
1194
1790
|
try { prev(undefined); } catch { /* swallow */ }
|
|
1195
1791
|
} else if (typeof prev === 'object') {
|
|
1196
|
-
prev.value = undefined;
|
|
1792
|
+
try { prev.value = undefined; } catch { /* swallow */ }
|
|
1197
1793
|
}
|
|
1198
1794
|
/** @type any */ (p).lastTarget = undefined;
|
|
1199
1795
|
/** @type any */ (p).__lastEl = undefined;
|
|
@@ -1245,43 +1841,130 @@ function reconcileRepeat(part, value) {
|
|
|
1245
1841
|
|
|
1246
1842
|
const newMap = new Map();
|
|
1247
1843
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
} else {
|
|
1261
|
-
if (existing) {
|
|
1262
|
-
disposeInstance(existing);
|
|
1263
|
-
removeBetween(existing.startNode, existing.endNode);
|
|
1844
|
+
try {
|
|
1845
|
+
// Walk the new list and position each item's nodes immediately before the marker.
|
|
1846
|
+
for (let i = 0; i < items.length; i++) {
|
|
1847
|
+
const key = keyFn(items[i], i);
|
|
1848
|
+
const tr = templateFn(items[i], i);
|
|
1849
|
+
if (!isTemplate(tr)) continue;
|
|
1850
|
+
const existing = state.map.get(key);
|
|
1851
|
+
if (existing && existing.strings === /** @type any */ (tr).strings) {
|
|
1852
|
+
updateInstance(existing, /** @type any */ (tr).values);
|
|
1853
|
+
// Move nodes before marker preserving element identity.
|
|
1854
|
+
moveRange(existing.startNode, existing.endNode, parent, marker);
|
|
1855
|
+
newMap.set(key, existing);
|
|
1264
1856
|
state.map.delete(key);
|
|
1857
|
+
} else {
|
|
1858
|
+
if (existing) {
|
|
1859
|
+
// Unmapped BEFORE the row is touched, for the reason spelled out
|
|
1860
|
+
// in the leftover loop below: a key kept across a refused removal
|
|
1861
|
+
// points at a half-removed row, and reusing that row later walks
|
|
1862
|
+
// the removal off the end of the region. Same ordering, same
|
|
1863
|
+
// trade, and the two have to agree or the invariant the catch
|
|
1864
|
+
// relies on holds on one branch and not the other.
|
|
1865
|
+
state.map.delete(key);
|
|
1866
|
+
disposeInstance(existing);
|
|
1867
|
+
removeBetween(existing.startNode, existing.endNode);
|
|
1868
|
+
}
|
|
1869
|
+
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1870
|
+
parent.insertBefore(frag, marker);
|
|
1871
|
+
newMap.set(key, inst);
|
|
1265
1872
|
}
|
|
1266
|
-
const { inst, frag } = buildDetached(/** @type any */ (tr));
|
|
1267
|
-
parent.insertBefore(frag, marker);
|
|
1268
|
-
newMap.set(key, inst);
|
|
1269
1873
|
}
|
|
1270
|
-
}
|
|
1271
1874
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1875
|
+
// Remove any keys that remain in the old map. The key leaves the map
|
|
1876
|
+
// BEFORE its row is touched and the removal is in a `finally`, so at any
|
|
1877
|
+
// throw point `state.map` holds exactly the leftovers this pass has not
|
|
1878
|
+
// reached, and a row whose dispose threw still leaves the document.
|
|
1879
|
+
// Iterating a snapshot keeps the delete obviously safe rather than
|
|
1880
|
+
// relying on the reader knowing that deleting during a Map iteration is
|
|
1881
|
+
// legal.
|
|
1882
|
+
for (const [k, inst] of [...state.map]) {
|
|
1883
|
+
state.map.delete(k);
|
|
1884
|
+
try {
|
|
1885
|
+
disposeInstance(inst);
|
|
1886
|
+
} finally {
|
|
1887
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
state.map = newMap;
|
|
1891
|
+
} catch (err) {
|
|
1892
|
+
// The walk moves DOM and drains `state.map` incrementally while the
|
|
1893
|
+
// replacement map is accumulated locally, so a throw part-way through
|
|
1894
|
+
// leaves NEITHER map describing what is on screen: the already-processed
|
|
1895
|
+
// keys sit only in `newMap`, which is about to be discarded, while their
|
|
1896
|
+
// nodes stay in the document tracked by nothing. The next (perfectly
|
|
1897
|
+
// valid) render then rebuilds those keys from scratch and the orphaned
|
|
1898
|
+
// originals are never removed, so the list shows duplicated rows forever
|
|
1899
|
+
// with nothing logged after the first throw.
|
|
1900
|
+
//
|
|
1901
|
+
// Re-unite every instance still in the document under `state.map` (the
|
|
1902
|
+
// two maps are disjoint: a key lands in `newMap` only after being deleted
|
|
1903
|
+
// from `state.map`), so the map describes the DOM again and nothing is
|
|
1904
|
+
// orphaned. That is the whole repair. The next render is then an ORDINARY
|
|
1905
|
+
// reconcile against a truthful map, which repositions every row and
|
|
1906
|
+
// re-applies whatever the throw skipped.
|
|
1907
|
+
//
|
|
1908
|
+
// That claim covers the REMOVAL loop as well as the walk, and only
|
|
1909
|
+
// because the loop was written to earn it. It drops each key before
|
|
1910
|
+
// touching that row and removes the nodes in a `finally`, so a throw
|
|
1911
|
+
// mid-removal cannot merge `newMap` over a `state.map` still holding
|
|
1912
|
+
// disposed, detached rows. That was the failure: the row the app DELETED
|
|
1913
|
+
// stayed on screen, the survivors reordered, and a later render that
|
|
1914
|
+
// re-added that key reinserted the detached instance. The invariant, at
|
|
1915
|
+
// any throw point on either branch: every instance this pass has not
|
|
1916
|
+
// destructively touched is described by exactly one of the two maps,
|
|
1917
|
+
// `newMap` for the processed new keys and `state.map` for the leftovers
|
|
1918
|
+
// not reached yet, which is what makes the merge below correct. The
|
|
1919
|
+
// exception is the row named in the residual just below, whose removal
|
|
1920
|
+
// refused part-way; that one is in neither map, by choice.
|
|
1921
|
+
//
|
|
1922
|
+
// The residual is a throw from `removeBetween` ITSELF, which only calls
|
|
1923
|
+
// `removeChild` on nodes the renderer owns, so it takes a throwing DOM to
|
|
1924
|
+
// reach. That row is already unmapped, so its remaining nodes stay in the
|
|
1925
|
+
// document tracked by nothing and a later re-add of that key builds a
|
|
1926
|
+
// second row beside them. Unmapping AFTER the removal instead would keep
|
|
1927
|
+
// that key, and it is measurably worse rather than better: the row is
|
|
1928
|
+
// half removed, its start marker gone and its end marker still in place,
|
|
1929
|
+
// so the re-add hits the reuse branch and `moveRange` re-attaches the
|
|
1930
|
+
// lone start marker AFTER the end marker. The next removal of that key
|
|
1931
|
+
// then walks forward from a start that never reaches its end, taking the
|
|
1932
|
+
// repeat part's own marker and every following sibling with it, and the
|
|
1933
|
+
// region is dead for good. One untracked row beats a destroyed list.
|
|
1934
|
+
//
|
|
1935
|
+
// Deliberately NOT a teardown-and-rebuild of the region. Rebuilding is
|
|
1936
|
+
// the obvious defensive move and it is measurably worse: it discards node
|
|
1937
|
+
// identity for every row, which is the exact cost keyed reconciliation
|
|
1938
|
+
// exists to avoid (see the plain-array note below, a rebuild cancels an
|
|
1939
|
+
// in-progress native drag). It is also unnecessary, because this map is
|
|
1940
|
+
// restored to the truth rather than left describing a DOM that moved, so
|
|
1941
|
+
// there is nothing to guess and no partial DOM move to unwind.
|
|
1942
|
+
//
|
|
1943
|
+
// That is only half of it, and the other half does NOT live here: the row
|
|
1944
|
+
// whose own commit threw is repaired by the COMMIT_FAILED sentinel in
|
|
1945
|
+
// `updateInstance`, not by anything below. Without it the failed hole
|
|
1946
|
+
// compares EQUAL on the next render (its `lastValues` entry never
|
|
1947
|
+
// advanced past the throw, so it still holds the value that render
|
|
1948
|
+
// supplies) and is skipped forever, which for a child position means the
|
|
1949
|
+
// row stays permanently blank. Do not remove the sentinel on the theory
|
|
1950
|
+
// that a half-updated instance heals itself; it does not.
|
|
1951
|
+
for (const [k, inst] of newMap) state.map.set(k, inst);
|
|
1952
|
+
throw err;
|
|
1276
1953
|
}
|
|
1277
|
-
state.map = newMap;
|
|
1278
1954
|
}
|
|
1279
1955
|
|
|
1280
1956
|
/** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
|
|
1281
1957
|
function teardownRepeat(state) {
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1958
|
+
// Same delete-as-you-go shape as the leftover loop in `reconcileRepeat`,
|
|
1959
|
+
// for the same reason: a throw part-way must not leave already-removed
|
|
1960
|
+
// instances in the map. The trailing `clear()` stays as a no-op safety net.
|
|
1961
|
+
for (const [k, inst] of [...state.map]) {
|
|
1962
|
+
state.map.delete(k);
|
|
1963
|
+
try {
|
|
1964
|
+
disposeInstance(inst);
|
|
1965
|
+
} finally {
|
|
1966
|
+
removeBetween(inst.startNode, inst.endNode);
|
|
1967
|
+
}
|
|
1285
1968
|
}
|
|
1286
1969
|
state.map.clear();
|
|
1287
1970
|
}
|
|
@@ -1372,42 +2055,107 @@ function reconcileArray(part, value) {
|
|
|
1372
2055
|
const old = state.items;
|
|
1373
2056
|
/** @type {ArrayItem[]} */
|
|
1374
2057
|
const next = [];
|
|
2058
|
+
// How many slots of `old` are fully processed. Tracked rather than inferred
|
|
2059
|
+
// from `next.length`, because the shrink loop below advances through `old`
|
|
2060
|
+
// while `next` stops growing, so the two part company there. The catch is
|
|
2061
|
+
// the only reader.
|
|
2062
|
+
let consumed = 0;
|
|
1375
2063
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (o
|
|
1390
|
-
|
|
2064
|
+
try {
|
|
2065
|
+
for (let i = 0; i < value.length; i++) {
|
|
2066
|
+
const v = value[i];
|
|
2067
|
+
const o = old[i];
|
|
2068
|
+
if (isTemplate(v)) {
|
|
2069
|
+
const tr = /** @type any */ (v);
|
|
2070
|
+
if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
|
|
2071
|
+
updateInstance(o.inst, tr.values);
|
|
2072
|
+
next.push(o);
|
|
2073
|
+
consumed = i + 1;
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
} else if (v != null && v !== false && v !== true) {
|
|
2077
|
+
if (o && o.type === 'text') {
|
|
2078
|
+
const str = String(v);
|
|
2079
|
+
if (o.node.data !== str) o.node.data = str;
|
|
2080
|
+
next.push(o);
|
|
2081
|
+
consumed = i + 1;
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
} else {
|
|
2085
|
+
// Empty slot: drop any prior nodes that occupied this position.
|
|
2086
|
+
// Deliberately NOT reordered like the branch below. That reorder
|
|
2087
|
+
// exists to keep a slot that was already BUILT and INSERTED tracked,
|
|
2088
|
+
// and this branch builds and inserts nothing, so pushing first would
|
|
2089
|
+
// only mean a throw from the removal leaves a phantom empty slot at
|
|
2090
|
+
// this index AND `old[i]` spliced in at the next one, shifting every
|
|
2091
|
+
// later slot by one in a POSITIONAL reconciler. Removing first, a
|
|
2092
|
+
// throw here leaves `old[i]` describing its own position, which is
|
|
2093
|
+
// still exactly where its nodes are.
|
|
2094
|
+
if (o) removeArrayItem(o);
|
|
2095
|
+
next.push({ type: 'empty' });
|
|
2096
|
+
consumed = i + 1;
|
|
1391
2097
|
continue;
|
|
1392
2098
|
}
|
|
1393
|
-
|
|
1394
|
-
//
|
|
2099
|
+
// Shape changed, or the array grew past the old length. Build fresh,
|
|
2100
|
+
// insert at this position (before the current / next still-attached
|
|
2101
|
+
// old node, else the marker), then drop the old slot it replaced.
|
|
2102
|
+
// The push sits BEFORE the removal, which is a pure reordering on the
|
|
2103
|
+
// success path and means a slot that has already been built and
|
|
2104
|
+
// inserted is never untracked at any throw point.
|
|
2105
|
+
const { item, frag } = buildArrayItem(v);
|
|
2106
|
+
if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
|
|
2107
|
+
next.push(item);
|
|
1395
2108
|
if (o) removeArrayItem(o);
|
|
1396
|
-
|
|
1397
|
-
continue;
|
|
2109
|
+
consumed = i + 1;
|
|
1398
2110
|
}
|
|
1399
|
-
// Shape changed, or the array grew past the old length. Build fresh,
|
|
1400
|
-
// insert at this position (before the current / next still-attached
|
|
1401
|
-
// old node, else the marker), then drop the old slot it replaced.
|
|
1402
|
-
const { item, frag } = buildArrayItem(v);
|
|
1403
|
-
if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
|
|
1404
|
-
if (o) removeArrayItem(o);
|
|
1405
|
-
next.push(item);
|
|
1406
|
-
}
|
|
1407
2111
|
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
2112
|
+
// Shrink: remove slots beyond the new length.
|
|
2113
|
+
for (let i = value.length; i < old.length; i++) {
|
|
2114
|
+
removeArrayItem(old[i]);
|
|
2115
|
+
consumed = i + 1;
|
|
2116
|
+
}
|
|
2117
|
+
state.items = next;
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
// `state.items` is committed only after the whole walk, so a throw part
|
|
2120
|
+
// way through discards `next` entirely: the map of slots keeps describing
|
|
2121
|
+
// positions whose nodes were already removed, while the freshly built and
|
|
2122
|
+
// inserted ones are in the document tracked by nothing. Nothing is logged
|
|
2123
|
+
// after the first throw, and the orphan outlives even a render of an EMPTY
|
|
2124
|
+
// array, because the only code that could remove it walks `state.items`.
|
|
2125
|
+
//
|
|
2126
|
+
// Splice the untouched tail of `old` onto what `next` accumulated, so the
|
|
2127
|
+
// bookkeeping describes the DOM again. The invariant that holds at any
|
|
2128
|
+
// throw point: every live node is described by exactly one slot, the
|
|
2129
|
+
// slots below `next.length` being the rebuilt or reused ones and the rest
|
|
2130
|
+
// the part of `old` this pass never reached. Index alignment survives
|
|
2131
|
+
// because this reconciler is POSITIONAL, so a slot's index IS its
|
|
2132
|
+
// identity, which is also why the boundary has to come from `consumed`
|
|
2133
|
+
// rather than `next.length`: during the shrink loop those differ, and
|
|
2134
|
+
// splicing from `next.length` would re-describe slots already removed.
|
|
2135
|
+
// A later render that grew the array would then match a live value
|
|
2136
|
+
// against a DETACHED slot with the same `strings`, update it in place,
|
|
2137
|
+
// and that row would silently never appear.
|
|
2138
|
+
//
|
|
2139
|
+
// Deliberately NOT a teardown-and-rebuild of the region, for the reason
|
|
2140
|
+
// recorded on `reconcileRepeat`'s catch above: it discards node identity
|
|
2141
|
+
// for every row, which cancels an in-progress native drag and drops focus
|
|
2142
|
+
// and scroll.
|
|
2143
|
+
//
|
|
2144
|
+
// The residual is a throw from the removal step itself, which takes a
|
|
2145
|
+
// throwing DOM to reach (the teardown it calls is total, so only
|
|
2146
|
+
// `removeBetween` is left, and that calls `removeChild` solely on nodes
|
|
2147
|
+
// the renderer owns). State it rather than deny it: `removeBetween`
|
|
2148
|
+
// takes the start marker first and then early-returns for good once that
|
|
2149
|
+
// marker is gone, so a row whose removal refused part-way can never be
|
|
2150
|
+
// removed afterwards. Its remaining nodes stay in the document, and an
|
|
2151
|
+
// EMPTY render will not clear them. Tracked or not, they are there for
|
|
2152
|
+
// the life of the region, the same residual `reconcileRepeat`'s catch
|
|
2153
|
+
// names. What this repair buys is that there is only ONE such row and
|
|
2154
|
+
// every other slot still reconciles, where before the whole pass was
|
|
2155
|
+
// discarded.
|
|
2156
|
+
state.items = next.concat(old.slice(consumed));
|
|
2157
|
+
throw err;
|
|
2158
|
+
}
|
|
1411
2159
|
}
|
|
1412
2160
|
|
|
1413
2161
|
/**
|
|
@@ -1688,6 +2436,9 @@ function applyUntil(part, args) {
|
|
|
1688
2436
|
// a fresh TemplateResult on every call but the strings array is
|
|
1689
2437
|
// interned per call site, so the conceptual value is unchanged.
|
|
1690
2438
|
const partAny = /** @type any */ (part);
|
|
2439
|
+
// Same as applyWatch: the promise handlers below commit with no render on
|
|
2440
|
+
// the stack, so the owning component is recorded here while it is knowable.
|
|
2441
|
+
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
1691
2442
|
const prevState = partAny.__untilState;
|
|
1692
2443
|
const prevArgs = partAny.__untilArgs;
|
|
1693
2444
|
const argEq = (a, b) => {
|
|
@@ -1754,8 +2505,19 @@ function applyUntil(part, args) {
|
|
|
1754
2505
|
(resolved) => {
|
|
1755
2506
|
if (state.aborted) return;
|
|
1756
2507
|
if (i >= state.highestResolved) return;
|
|
2508
|
+
// Commit FIRST, record the new priority only once it succeeded.
|
|
2509
|
+
// Advancing `highestResolved` up front meant a commit throw left the
|
|
2510
|
+
// region torn down while the state claimed index `i` had won, so
|
|
2511
|
+
// every later resolution at a lower priority was refused and the
|
|
2512
|
+
// region stayed empty. Committing first also keeps the throw inside
|
|
2513
|
+
// the try, where it can reach the component boundary.
|
|
2514
|
+
try {
|
|
2515
|
+
commitOutOfBand(part, () => applyChildInner(part, resolved));
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
reportOutOfBandCommitError(part, err);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
1757
2520
|
state.highestResolved = i;
|
|
1758
|
-
applyChildInner(part, resolved);
|
|
1759
2521
|
},
|
|
1760
2522
|
() => {
|
|
1761
2523
|
// Swallow rejection. A rejected Promise is treated as "no value";
|
|
@@ -1765,6 +2527,54 @@ function applyUntil(part, args) {
|
|
|
1765
2527
|
}
|
|
1766
2528
|
}
|
|
1767
2529
|
|
|
2530
|
+
/**
|
|
2531
|
+
* Route a throw from an OUT-OF-BAND commit to the owning component's
|
|
2532
|
+
* render-error boundary.
|
|
2533
|
+
*
|
|
2534
|
+
* `watch`'s notify microtask and `until`'s Promise resolution commit from
|
|
2535
|
+
* outside `component.js`'s update cycle, so none of the boundaries that wrap
|
|
2536
|
+
* every other render path are on the stack. Left alone, a commit throw there
|
|
2537
|
+
* escapes as a window-level `error` / unhandled rejection instead of the
|
|
2538
|
+
* per-component `renderError()` the sync and async render paths both route
|
|
2539
|
+
* to, which breaks per-component error isolation for exactly these two
|
|
2540
|
+
* directives.
|
|
2541
|
+
*
|
|
2542
|
+
* Reads the owner stamped on the part when the directive was installed. See
|
|
2543
|
+
* the note in the body for why this is not a walk up the parent chain.
|
|
2544
|
+
*
|
|
2545
|
+
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
2546
|
+
* @param {unknown} error
|
|
2547
|
+
*/
|
|
2548
|
+
function reportOutOfBandCommitError(part, error) {
|
|
2549
|
+
let err;
|
|
2550
|
+
try {
|
|
2551
|
+
err = error instanceof Error ? error : new Error(String(error));
|
|
2552
|
+
} catch {
|
|
2553
|
+
// The thrown value's own `toString` threw. That is the exact class of
|
|
2554
|
+
// value that makes a commit throw in the first place, so it is reachable
|
|
2555
|
+
// here; do not let stringifying it mask the original failure.
|
|
2556
|
+
err = new Error('render error (unstringifiable thrown value)');
|
|
2557
|
+
}
|
|
2558
|
+
// The owner stamped when the directive was installed, which is the
|
|
2559
|
+
// component whose TEMPLATE holds this part. Never walk the parent chain
|
|
2560
|
+
// for this: `_handleRenderError` lives on WebComponent's prototype, so
|
|
2561
|
+
// every upgraded element on the way up carries one, and the FIRST one a
|
|
2562
|
+
// structural walk meets is the innermost element the part happens to sit
|
|
2563
|
+
// inside, not the template that owns it. Routing there is not merely the
|
|
2564
|
+
// wrong log line: a light-DOM component's renderError() commits into the
|
|
2565
|
+
// component itself, which would replace the very children holding this
|
|
2566
|
+
// part's markers and silently kill every later update through it.
|
|
2567
|
+
const owner = /** @type any */ (part).__commitOwner;
|
|
2568
|
+
if (owner && typeof owner._handleRenderError === 'function') {
|
|
2569
|
+
owner._handleRenderError(err);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
// No component boundary owns this part (a bare `render()` into a plain
|
|
2573
|
+
// container). Nothing can contain the error, so surface it rather than
|
|
2574
|
+
// swallow it.
|
|
2575
|
+
throw err;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
1768
2578
|
/**
|
|
1769
2579
|
* Abort an `until` directive's in-flight Promise tracking. Called from
|
|
1770
2580
|
* `teardownChild` when the part is being reset.
|
|
@@ -1796,6 +2606,11 @@ function teardownUntil(state) {
|
|
|
1796
2606
|
*/
|
|
1797
2607
|
function applyWatch(part, sig) {
|
|
1798
2608
|
const partAny = /** @type any */ (part);
|
|
2609
|
+
// Record the owning component while we are still inside its render(), the
|
|
2610
|
+
// only moment it is knowable. The notify microtask below commits with no
|
|
2611
|
+
// render on the stack and no way to derive it. Keep a prior stamp if this
|
|
2612
|
+
// somehow runs outside a render, rather than clearing a good owner.
|
|
2613
|
+
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
1799
2614
|
// Same signal as last render: refresh dep tracking via observe (the
|
|
1800
2615
|
// spec-aligned Watcher fires once per arm, so re-observing re-arms).
|
|
1801
2616
|
if (partAny.__watchSig === sig && partAny.__watchSub) {
|
|
@@ -1817,8 +2632,15 @@ function applyWatch(part, sig) {
|
|
|
1817
2632
|
queueMicrotask(() => {
|
|
1818
2633
|
if (partAny.__watchSub !== watcher) return; // disposed mid-flight
|
|
1819
2634
|
let v;
|
|
1820
|
-
|
|
1821
|
-
|
|
2635
|
+
// Nothing above this microtask catches: it runs outside the host's
|
|
2636
|
+
// update cycle, so a commit throw here would surface at the window
|
|
2637
|
+
// instead of the component's renderError(). Route it explicitly.
|
|
2638
|
+
try {
|
|
2639
|
+
watcher.observe(() => { v = sig.get(); });
|
|
2640
|
+
commitOutOfBand(part, () => applyChildInner(part, v));
|
|
2641
|
+
} catch (err) {
|
|
2642
|
+
reportOutOfBandCommitError(part, err);
|
|
2643
|
+
}
|
|
1822
2644
|
});
|
|
1823
2645
|
});
|
|
1824
2646
|
partAny.__watchSub = watcher;
|
|
@@ -1857,6 +2679,15 @@ function teardownWatch(partAny) {
|
|
|
1857
2679
|
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
1858
2680
|
*/
|
|
1859
2681
|
function applyAsyncAppend(part, dir) {
|
|
2682
|
+
const partAny = /** @type any */ (part);
|
|
2683
|
+
// Record the owning component while we are still inside its render(), the
|
|
2684
|
+
// only moment it is knowable, exactly as `applyWatch` / `applyUntil` do.
|
|
2685
|
+
// Chunks commit from an async loop with no render on the stack, so without
|
|
2686
|
+
// this both the chunk's own commit throw and any directive nested inside a
|
|
2687
|
+
// chunk have no owner to route to. Stamped ABOVE the short-circuit so a
|
|
2688
|
+
// re-render that returns early still refreshes the owner, and guarded so a
|
|
2689
|
+
// re-install outside a render keeps a previously good one.
|
|
2690
|
+
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
1860
2691
|
// Same-iterable short-circuit: if the prior render's iterable identity
|
|
1861
2692
|
// matches, the existing iterator is still consuming it. Re-subscribing
|
|
1862
2693
|
// would start a fresh iterator that misses already-yielded values.
|
|
@@ -1895,6 +2726,9 @@ function applyAsyncAppend(part, dir) {
|
|
|
1895
2726
|
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
1896
2727
|
*/
|
|
1897
2728
|
function applyAsyncReplace(part, dir) {
|
|
2729
|
+
const partAny = /** @type any */ (part);
|
|
2730
|
+
// Owner stamp: see comment in applyAsyncAppend. Above the short-circuit.
|
|
2731
|
+
if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
|
|
1898
2732
|
// Same-iterable short-circuit: see comment in applyAsyncAppend.
|
|
1899
2733
|
const currentChild = /** @type any */ (part.child);
|
|
1900
2734
|
if (currentChild && currentChild.kind === 'async-stream'
|
|
@@ -1941,6 +2775,23 @@ function applyAsyncReplace(part, dir) {
|
|
|
1941
2775
|
* after every `next()` resolve to short-circuit if abortion happened
|
|
1942
2776
|
* while the iterator was suspended.
|
|
1943
2777
|
*
|
|
2778
|
+
* Each pass carries TWO try spans, and which failure lands in which is the
|
|
2779
|
+
* load-bearing part. SPAN A is the author's own code, the iterable AND the
|
|
2780
|
+
* `mapper` it was given, and a throw from either is logged to the console and
|
|
2781
|
+
* ends the stream, on the long-standing reasoning that an author's iterable
|
|
2782
|
+
* should handle its own errors. SPAN B is the chunk COMMIT, which is a render
|
|
2783
|
+
* failure of the component whose template holds the binding, so it routes to
|
|
2784
|
+
* that component's `renderError()` and stops the stream.
|
|
2785
|
+
*
|
|
2786
|
+
* Scope note: only a throw from the COMMIT can stop the stream from here. A
|
|
2787
|
+
* directive nested INSIDE a committed chunk (a `watch` whose signal changes
|
|
2788
|
+
* later) throws from its own handler, outside this loop entirely, so it
|
|
2789
|
+
* reaches the boundary but this loop knows nothing about it and keeps
|
|
2790
|
+
* pulling. That is the same for any directive nested anywhere else. lit is no authority
|
|
2791
|
+
* either way here (it has no per-component boundary, and both failures become
|
|
2792
|
+
* unhandled rejections at the window), so this follows the
|
|
2793
|
+
* per-component error isolation WebJs has instead.
|
|
2794
|
+
*
|
|
1944
2795
|
* @param {AsyncStreamState} state
|
|
1945
2796
|
* @param {Extract<BoundPart, {kind:'child'}>} part
|
|
1946
2797
|
* @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
|
|
@@ -1948,39 +2799,84 @@ function applyAsyncReplace(part, dir) {
|
|
|
1948
2799
|
async function consumeAsyncStream(state, part, dir) {
|
|
1949
2800
|
const marker = part.marker;
|
|
1950
2801
|
let i = 0;
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2802
|
+
while (!state.aborted) {
|
|
2803
|
+
/** @type {IteratorResult<unknown>} */
|
|
2804
|
+
let result;
|
|
2805
|
+
/** @type {unknown} */
|
|
2806
|
+
let mapped;
|
|
2807
|
+
// SPAN A, the author's iterable. A throw here is the author's generator
|
|
2808
|
+
// failing, not a render, so it keeps the long-standing console.error and
|
|
2809
|
+
// ends the stream. It is a separate span from the commit below on purpose
|
|
2810
|
+
// rather than a flag the one catch inspects, because the distinction is
|
|
2811
|
+
// the whole point: `reportOutOfBandCommitError` RETHROWS for a part with
|
|
2812
|
+
// no owner, and a single enclosing try would hand that rethrow straight
|
|
2813
|
+
// back to this swallow, which is the escape this split exists to stop.
|
|
2814
|
+
try {
|
|
2815
|
+
result = await state.iterator.next();
|
|
1954
2816
|
if (state.aborted) break;
|
|
1955
2817
|
if (result.done) break;
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
//
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
2818
|
+
mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
|
|
2819
|
+
} catch (err) {
|
|
2820
|
+
// Note this ENDS the stream, and always has: the catch used to sit
|
|
2821
|
+
// outside the loop, so there has never been a resume path here.
|
|
2822
|
+
if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// SPAN B, the chunk commit. This is a render of the component whose
|
|
2827
|
+
// TEMPLATE holds the binding, so a throw is that component's render
|
|
2828
|
+
// failure and routes to its `renderError()`, the same as `watch` and
|
|
2829
|
+
// `until` already do from their own out-of-band commits. `renderToNodes`
|
|
2830
|
+
// is INSIDE the wrap because that is where a nested directive is
|
|
2831
|
+
// installed and reads `currentRenderRoot`; without it, a `watch()` inside
|
|
2832
|
+
// a chunk is stamped with no owner and its later throw escapes.
|
|
2833
|
+
// `commitInto` is a different concern (the renderer-write window for a
|
|
2834
|
+
// light slot host), so the two nest rather than replace each other.
|
|
2835
|
+
try {
|
|
2836
|
+
commitOutOfBand(part, () => {
|
|
2837
|
+
const newNodes = renderToNodes(mapped);
|
|
2838
|
+
|
|
2839
|
+
// This chunk commit runs in an async loop OUTSIDE any render() window,
|
|
2840
|
+
// so open the renderer-write window explicitly: without it, committing a
|
|
2841
|
+
// stream chunk into a light slot host would hit the patched insertBefore /
|
|
2842
|
+
// removeChild and fold the renderer's own output into `authored`.
|
|
2843
|
+
commitInto(marker.parentNode, () => {
|
|
2844
|
+
if (state.mode === 'replace') {
|
|
2845
|
+
for (const n of state.nodes) {
|
|
2846
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
2847
|
+
}
|
|
2848
|
+
state.nodes = [];
|
|
1967
2849
|
}
|
|
1968
|
-
state.nodes = [];
|
|
1969
|
-
}
|
|
1970
2850
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2851
|
+
const frag = document.createDocumentFragment();
|
|
2852
|
+
for (const n of newNodes) frag.appendChild(n);
|
|
2853
|
+
marker.parentNode?.insertBefore(frag, marker);
|
|
2854
|
+
state.nodes.push(...newNodes);
|
|
2855
|
+
});
|
|
1975
2856
|
});
|
|
1976
|
-
|
|
1977
|
-
|
|
2857
|
+
} catch (err) {
|
|
2858
|
+
// Stop the stream. The boundary is about to render an error state, and
|
|
2859
|
+
// appending later chunks into a region it may have replaced is not a
|
|
2860
|
+
// recovery. The rendered nodes are left alone: blanking the region is a
|
|
2861
|
+
// separate decision, and `teardownAsyncStream` is for the part being
|
|
2862
|
+
// reset, not for this.
|
|
2863
|
+
state.aborted = true;
|
|
2864
|
+
try { state.iterator.return?.()?.catch?.(() => {}); } catch { /* best effort */ }
|
|
2865
|
+
// Rethrows when nothing can receive the error, which for a bare
|
|
2866
|
+
// `render()` into a plain container is an owner that carries no
|
|
2867
|
+
// `_handleRenderError` (the stamp records the container itself, so the
|
|
2868
|
+
// owner is present, just not a component). Surfacing beats swallowing
|
|
2869
|
+
// there, and it matches `watch` and `until`, which rethrow from their
|
|
2870
|
+
// own out-of-band handlers for the same reason. The exact shape differs
|
|
2871
|
+
// by site rather than being one thing: this rejects the loop's
|
|
2872
|
+
// promise, `until` rejects from its `.then`, and `watch` throws inside
|
|
2873
|
+
// a `queueMicrotask`, which is an uncaught error rather than a
|
|
2874
|
+
// rejection.
|
|
2875
|
+
reportOutOfBandCommitError(part, err);
|
|
2876
|
+
return;
|
|
1978
2877
|
}
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
// crash the host's render cycle. Authors who care about errors
|
|
1982
|
-
// should handle them in their iterable / generator.
|
|
1983
|
-
if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
|
|
2878
|
+
|
|
2879
|
+
i++;
|
|
1984
2880
|
}
|
|
1985
2881
|
}
|
|
1986
2882
|
|
|
@@ -1994,12 +2890,13 @@ function renderToNodes(value) {
|
|
|
1994
2890
|
if (value == null || value === false || value === true) return [];
|
|
1995
2891
|
if (isTemplate(value)) {
|
|
1996
2892
|
const tr = /** @type any */ (value);
|
|
1997
|
-
const { templateEl, parts } = compile(tr);
|
|
2893
|
+
const { templateEl, parts, formActions } = compile(tr);
|
|
1998
2894
|
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
1999
2895
|
const bound = parts.map((p) => bindPart(p, frag));
|
|
2000
2896
|
for (let i = 0; i < tr.values.length; i++) {
|
|
2001
2897
|
applyPart(bound[i], tr.values[i], undefined, tr.values);
|
|
2002
2898
|
}
|
|
2899
|
+
reconcileFormActions(formActions, bound, tr.values);
|
|
2003
2900
|
// Slot parts need their one-shot apply here too (same contract as
|
|
2004
2901
|
// createInstance / nested templates / buildDetached): the caller
|
|
2005
2902
|
// (consumeAsyncStream) inserts these nodes synchronously in the same
|