@webjsdev/core 0.7.46 → 0.7.48

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.
@@ -1,7 +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 { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
4
+ import {
5
+ assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
6
+ reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName,
7
+ assertSubmitterType, assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
8
+ assertSubmitterHasNoFormAttribute, assertSingleSubmitterAction,
9
+ resolveBoundSubmitterAttrs, applyResolvedAttr, releaseSubmitterAttrs,
10
+ assertConvergentSubmitter, isSubmitterReflectedProp,
11
+ formActionId, assertIdentifiableAction, FORM_ACTION_FIELD,
12
+ } from './form-action.js';
5
13
  import { isRepeat } from './repeat.js';
6
14
  import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
7
15
  import { Signal } from './signal.js';
@@ -64,8 +72,44 @@ function commitInto(node, fn) {
64
72
  * the `repeat()` directive.
65
73
  */
66
74
 
67
- /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
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 }>} */
68
106
  const templateCache = new WeakMap();
107
+ /**
108
+ * Submitters this renderer stamped with an identity, so a later release removes
109
+ * only the framework's own `name` / `value` and never an author's.
110
+ * @type {WeakMap<Element, string>}
111
+ */
112
+ const submitterActionBindings = new WeakMap();
69
113
  const INSTANCE = Symbol.for('webjs.instance');
70
114
 
71
115
  /**
@@ -98,6 +142,54 @@ const INSTANCE = Symbol.for('webjs.instance');
98
142
  * } BoundPart
99
143
  */
100
144
 
145
+ /**
146
+ * The container the in-progress `render()` is committing into, or null
147
+ * outside one. Every part created or applied during that render belongs to
148
+ * THIS container's component, which is what an out-of-band commit needs to
149
+ * know to reach the right error boundary. A structural parent walk cannot
150
+ * work it out: `html`<child-el>${watch(sig)}</child-el>`` puts the part in the
151
+ * PARENT's template but inside the child's tag, so the walk meets the child
152
+ * first. Same reason `SLOT_OWNER` exists.
153
+ * @type {any}
154
+ */
155
+ let currentRenderRoot = null;
156
+
157
+ /**
158
+ * The element carrying the error boundary for a render root. A ShadowRoot has
159
+ * no boundary of its own; its `.host` is the component.
160
+ * @param {any} root
161
+ * @returns {any}
162
+ */
163
+ function boundaryOwnerOf(root) {
164
+ if (!root) return null;
165
+ if (root.nodeType === 11 && root.host) return root.host;
166
+ return root;
167
+ }
168
+
169
+ /**
170
+ * Run an OUT-OF-BAND commit (a `watch` notify microtask, an `until`
171
+ * resolution) with the part's owner installed as the current render root.
172
+ *
173
+ * Without this the commit runs with no owner in scope, so any `watch` /
174
+ * `until` nested INSIDE the template it commits is installed unstamped and
175
+ * its own later throw escapes to the window. A directive nested in that
176
+ * template belongs to the same component as the part committing it, which is
177
+ * exactly the owner already recorded here.
178
+ *
179
+ * @param {Extract<BoundPart, {kind:'child'}>} part
180
+ * @param {() => void} fn
181
+ */
182
+ function commitOutOfBand(part, fn) {
183
+ const owner = /** @type any */ (part).__commitOwner;
184
+ const prev = currentRenderRoot;
185
+ if (owner) currentRenderRoot = owner;
186
+ try {
187
+ fn();
188
+ } finally {
189
+ currentRenderRoot = prev;
190
+ }
191
+ }
192
+
101
193
  /**
102
194
  * Render a value into a container, reusing DOM where possible.
103
195
  *
@@ -113,6 +205,8 @@ export function render(value, container) {
113
205
  // between a renderer commit and an author write.
114
206
  const prevRendering = host[RENDERING];
115
207
  host[RENDERING] = true;
208
+ const prevRenderRoot = currentRenderRoot;
209
+ currentRenderRoot = container;
116
210
  try {
117
211
  const prev = host[INSTANCE];
118
212
 
@@ -160,6 +254,7 @@ export function render(value, container) {
160
254
  }
161
255
  container.appendChild(document.createTextNode(String(value)));
162
256
  } finally {
257
+ currentRenderRoot = prevRenderRoot;
163
258
  host[RENDERING] = prevRendering;
164
259
  // Outermost window closing: drain this commit's childList records off the
165
260
  // slot backstop (drainRendererBackstop processes them with a renderer-output
@@ -400,10 +495,13 @@ function compile(tr) {
400
495
  // discovery step adds are picked up in the same path-recording walk.
401
496
  discoverSlots(templateEl.content, parts);
402
497
 
403
- // Walk the parsed fragment and record DOM paths for each part.
404
- assignPaths(templateEl.content, parts);
498
+ // Walk the parsed fragment and record DOM paths for each part. It also
499
+ // returns the per-template form-action records (#1155), null when the
500
+ // template contains no bound-form candidate, which is the overwhelmingly
501
+ // common case and keeps the reconcile out of every other template's path.
502
+ const formActions = assignPaths(templateEl.content, parts);
405
503
 
406
- cached = { templateEl, parts };
504
+ cached = { templateEl, parts, formActions };
407
505
  templateCache.set(strings, cached);
408
506
  return cached;
409
507
  }
@@ -461,6 +559,8 @@ function discoverSlots(root, parts) {
461
559
  function assignPaths(root, parts) {
462
560
  /** @type {number[]} */
463
561
  const path = [];
562
+ /** @type {FormActionRecord[]} */
563
+ const formActions = [];
464
564
  /** @param {Node} node */
465
565
  function visit(node) {
466
566
  for (let i = 0; i < node.childNodes.length; i++) {
@@ -479,15 +579,30 @@ function assignPaths(root, parts) {
479
579
  const el = /** @type Element */ (child);
480
580
  // Sentinel attribute?
481
581
  const toRemove = [];
582
+ /** Every part bound to THIS element, which only this walk can see. */
583
+ /** @type {{ idx: number, kind: string, name: string }[]} */
584
+ const onEl = [];
482
585
  for (const attr of el.attributes) {
483
586
  if (attr.name.startsWith(`data-${MARKER}`)) {
484
587
  const idx = Number(attr.name.slice(`data-${MARKER}`.length));
485
588
  if (parts[idx] && parts[idx].kind !== 'child') {
486
589
  parts[idx].path = path.slice();
590
+ onEl.push({ idx, kind: parts[idx].kind, name: parts[idx].name || '' });
487
591
  }
488
592
  toRemove.push(attr.name);
489
593
  }
490
594
  }
595
+ // #1155 / #1207: record what a bound form or submitter needs, while the
596
+ // STATIC attributes and the element's part indices are both still
597
+ // visible. This is the only point in compilation with that whole view,
598
+ // and it runs once per template, so the record is a constant rather
599
+ // than runtime memory that can drift out of step with the DOM. It is
600
+ // also the only place a submitter's own `name` is still legible: by
601
+ // reconcile time the renderer has written the identity over it.
602
+ if (el.localName === 'form' || el.localName === 'button' || el.localName === 'input') {
603
+ const rec = buildFormActionRecord(el, onEl, parts);
604
+ if (rec) formActions.push(rec);
605
+ }
491
606
  for (const a of toRemove) el.removeAttribute(a);
492
607
  visit(child);
493
608
  }
@@ -495,6 +610,294 @@ function assignPaths(root, parts) {
495
610
  }
496
611
  }
497
612
  visit(root);
613
+ return formActions.length ? formActions : null;
614
+ }
615
+
616
+ /**
617
+ * Build the compile-time record for one `<form>` carrying an `action` hole, or
618
+ * one submitter carrying a `formaction` hole.
619
+ *
620
+ * A CANDIDATE, not a decision: `<form action=${maybeString}>` compiles exactly
621
+ * like a bound one, so whether the form is really bound is a runtime fact the
622
+ * reconcile decides from the value. Everything recorded here is a property of
623
+ * the TEMPLATE, which is why it cannot go stale the way remembering "did the
624
+ * bind supply this attribute" at runtime did.
625
+ *
626
+ * `encoding` folds into `enctype` for a PROPERTY binding only: it is a legacy
627
+ * IDL alias, so `.encoding=` writes the enctype content attribute. As a content
628
+ * attribute `encoding=` is inert (a browser's `form.encoding` reads back
629
+ * `enctype`), and SSR ignores it, so folding the attribute spelling too would
630
+ * make the client honour a value the server never saw.
631
+ *
632
+ * The submitter fields (`authoredName` / `authoredValue` / `authoredForm`) are
633
+ * read off the COMPILED template rather than the live element, for the same
634
+ * reason: by reconcile time the renderer has written its own `name` / `value`
635
+ * onto a bound submitter, so the live element can no longer say which of them
636
+ * the author wrote. A hole-provided `name=${n}` leaves no attribute on the
637
+ * compiled template at all, which is why `nameParts` is tracked separately.
638
+ *
639
+ * @param {Element} el
640
+ * @param {{ idx: number, kind: string, name: string }[]} onEl parts bound to `el`
641
+ * @param {PartDescriptor[]} parts
642
+ * @returns {FormActionRecord | null}
643
+ */
644
+ function buildFormActionRecord(el, onEl, parts) {
645
+ const isForm = el.localName === 'form';
646
+ const targetAttr = isForm ? 'action' : 'formaction';
647
+ // The submission pair, named per element (#1307). A form declares `method` /
648
+ // `enctype`; a submitter declares `formmethod` / `formenctype`, and a bound
649
+ // one receives them from the renderer exactly as a bound form does.
650
+ const methodAttr = isForm ? 'method' : 'formmethod';
651
+ const enctypeAttr = isForm ? 'enctype' : 'formenctype';
652
+ const actionParts = onEl.filter((p) => p.kind === 'attr' && p.name.toLowerCase() === targetAttr);
653
+ if (!actionParts.length) return null;
654
+
655
+ /** @type {FormAttrPart[]} */
656
+ const methodParts = [];
657
+ /** @type {FormAttrPart[]} */
658
+ const enctypeParts = [];
659
+ /** Prop bindings that cannot converge with SSR; refused when actually bound. */
660
+ const propAttrs = [];
661
+ // `name` holes on a submitter, recorded WITH their kind. Whether one occupies
662
+ // the identity's channel is not a property of the hole alone: SSR emits
663
+ // `name=""` for an attribute hole whatever it resolved to, but emits nothing
664
+ // at all for a FALSY boolean hole and nothing for an `@name` listener. Asking
665
+ // only "is there a part called name" therefore refused templates SSR renders
666
+ // happily, which is the render-on-the-server-throw-on-hydration direction.
667
+ // The kinds travel with the record and `reconcileFormActions` resolves them.
668
+ const nameParts = isForm ? [] : onEl
669
+ .filter((p) => p.name.toLowerCase() === 'name'
670
+ && (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
671
+ .map((p) => ({ i: p.idx, kind: p.kind }));
672
+ const authoredName = !isForm && el.hasAttribute('name');
673
+ const authoredForm = !isForm && el.hasAttribute('form');
674
+ const authoredValue = !isForm && el.hasAttribute('value');
675
+ // `value` holes, recorded with their kind for the same reason as `name`: a
676
+ // FALSY boolean hole emits nothing at SSR, so counting the part's mere
677
+ // presence refused a template the server renders happily.
678
+ const valueParts = isForm ? [] : onEl
679
+ .filter((p) => p.name.toLowerCase() === 'value'
680
+ && (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool'))
681
+ .map((p) => ({ i: p.idx, kind: p.kind }));
682
+
683
+ for (const p of onEl) {
684
+ const name = String(p.name).toLowerCase();
685
+ if (p.kind === 'prop') {
686
+ // On a FORM, the reflected attributes that decide submittability. On a
687
+ // SUBMITTER, the ones that carry the identity or override the submission
688
+ // (#1207). Either way a `.prop` is dropped at SSR and written to the
689
+ // attribute in the browser, so it can never converge.
690
+ if (isForm) {
691
+ if (name === 'method' || name === 'enctype' || name === 'encoding') propAttrs.push(p.name);
692
+ } else if (isSubmitterReflectedProp(name)) {
693
+ propAttrs.push(p.name);
694
+ }
695
+ continue;
696
+ }
697
+ // #1307: a bound submitter carries its OWN submission attributes, so the
698
+ // pair to capture depends on the element. `formmethod` / `formenctype` on a
699
+ // button are exactly what `method` / `enctype` are on a form, and they flow
700
+ // through the same resolution below untouched.
701
+ if (name !== methodAttr && name !== enctypeAttr) continue;
702
+ if (p.kind !== 'attr' && p.kind !== 'attr-mixed' && p.kind !== 'bool') continue;
703
+ const d = /** @type any */ (parts[p.idx]);
704
+ /** @type {FormAttrPart} */
705
+ const entry = { i: p.idx, kind: p.kind };
706
+ // A mixed attribute's value is `statics[0] + v0 + statics[1] + ...`, so the
707
+ // pieces have to travel with the record; reading the anchor's value alone
708
+ // is right only when the statics are empty.
709
+ if (p.kind === 'attr-mixed') { entry.statics = d.statics || []; entry.group = d.group || []; }
710
+ // Compared against `methodAttr`, NOT the literal 'method'. On a submitter
711
+ // the pair is `formmethod` / `formenctype`, so a literal comparison sent
712
+ // every hole-provided `formmethod` into `enctypeParts`: the client then
713
+ // resolved it as the enctype, refused `formenctype="post"`, and threw on
714
+ // hydration for a template SSR renders happily. That is the
715
+ // render-on-the-server, crash-in-the-browser direction this module treats
716
+ // as the one unacceptable failure.
717
+ (name === methodAttr ? methodParts : enctypeParts).push(entry);
718
+ }
719
+
720
+ return {
721
+ isForm,
722
+ tag: el.localName,
723
+ // EVERY action hole, not just the first. SSR refuses two holes whenever
724
+ // ANY of them resolves to a bound action, so recording only the first would
725
+ // let `<form action=${'/legacy'} action=${boundFn}>` slip through the
726
+ // client's release path while SSR throws on the same template.
727
+ actionIdxs: actionParts.map((p) => p.idx),
728
+ duplicateAction: actionParts.length > 1,
729
+ // A static `action="..."` / `formaction="..."` surviving alongside the hole.
730
+ // The compiled template holds the hole as a sentinel attribute, so anything
731
+ // read back under the real name here is the author's own second one.
732
+ staticAction: el.getAttribute(targetAttr) != null,
733
+ authoredName,
734
+ authoredValue,
735
+ authoredForm,
736
+ propAttrs,
737
+ nameParts,
738
+ valueParts,
739
+ staticMethod: el.getAttribute(methodAttr),
740
+ staticEnctype: el.getAttribute(enctypeAttr),
741
+ methodParts,
742
+ enctypeParts,
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Converge every candidate bound form and submitter in this template, after all
748
+ * of its parts have committed. A no-op for the overwhelming majority of
749
+ * templates, which carry no `<form action=${...}>` at all and therefore no
750
+ * record.
751
+ *
752
+ * FORMS FIRST, submitters second, regardless of the order the records were
753
+ * collected in. A submitter asks whether its enclosing form is bound, and the
754
+ * cheapest true answer is the identity field the form's own reconcile just
755
+ * inserted. Document order gets this right for a form and a button in the SAME
756
+ * template, but not for a `<form>` whose submitter arrives through a nested
757
+ * template, so the ordering is made explicit rather than relied upon.
758
+ *
759
+ * @param {FormActionRecord[] | null} formActions
760
+ * @param {BoundPart[]} bound
761
+ * @param {unknown[]} values
762
+ */
763
+ function reconcileFormActions(formActions, bound, values) {
764
+ if (!formActions) return;
765
+ // Forms first, then submitters. The original motivation was a boundness read
766
+ // that is gone with #1307, but the ordering still makes a form's RELEASE run
767
+ // before its submitters reconcile, so it is kept rather than churned.
768
+ for (const pass of [true, false]) {
769
+ for (const rec of formActions) {
770
+ if (rec.isForm !== pass) continue;
771
+ // With more than one action hole, the BOUND one decides, whichever
772
+ // position it is written in. Picking `actionIdxs[0]` blindly would send
773
+ // `<form action=${'/url'} action=${boundFn}>` down the release path and
774
+ // ship the broken form SSR refuses outright.
775
+ let idx = rec.actionIdxs[0];
776
+ const targetAttr = rec.isForm ? 'action' : 'formaction';
777
+ for (const i of rec.actionIdxs) {
778
+ if (isBoundFormAction(resolveHoleValue(values[i]), targetAttr, rec.tag)) { idx = i; break; }
779
+ }
780
+ const part = bound[idx];
781
+ if (!part || !part.el) continue;
782
+ const val = resolveHoleValue(values[idx]);
783
+ if (rec.isForm) {
784
+ reconcileFormAction(
785
+ /** @type any */ (part.el),
786
+ val,
787
+ effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
788
+ effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
789
+ rec,
790
+ );
791
+ } else {
792
+ // What SSR would have emitted for this pass. An attribute hole always
793
+ // emits (even `name=${null}`, as `name=""`); a boolean hole emits only
794
+ // when truthy. Both identity channels ask the same question, through
795
+ // one predicate, so they cannot drift apart again.
796
+ const emits = (parts) => parts.some((np) => (np.kind === 'bool'
797
+ ? !!resolveHoleValue(values[np.i])
798
+ : true));
799
+ reconcileSubmitterAction(
800
+ /** @type any */ (part.el), val, rec,
801
+ emits(rec.nameParts), emits(rec.valueParts),
802
+ effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
803
+ effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
804
+ );
805
+ }
806
+ }
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Remove the identity channel previously injected into a submitter.
812
+ *
813
+ * Only ours is removed. `submitterActionBindings` remembers the elements this
814
+ * renderer stamped, and the `name` check covers an SSR'd button meeting its
815
+ * template for the first time on hydration. A button that never carried a
816
+ * binding is left alone, so an author's own `name` / `value` survives a
817
+ * re-render that happens to pass a non-action value.
818
+ *
819
+ * @param {Element} el
820
+ */
821
+ function releaseSubmitterAction(el) {
822
+ const injected = submitterActionBindings.has(el) || el.getAttribute('name') === FORM_ACTION_FIELD;
823
+ if (!injected) return;
824
+ if (el.getAttribute('name') === FORM_ACTION_FIELD) el.removeAttribute('name');
825
+ el.removeAttribute('value');
826
+ submitterActionBindings.delete(el);
827
+ }
828
+
829
+ /**
830
+ * Converge a live submitter on what SSR emitted for `<button formaction=${fn}>`
831
+ * (#1207, #1307): the `formaction` attribute gone, the identity in the button's
832
+ * own `name` / `value` pair, and the submission the framework supplies in its
833
+ * own `formmethod` / `formenctype`.
834
+ *
835
+ * There is no enclosing-form question here any more. It used to be the hard
836
+ * part of this function, asked once per element and BEST EFFORT because a
837
+ * fragment reconciling detached (a submitter inside a `repeat()` or an array
838
+ * item, whose form lives in the parent template) has no answer to give. With
839
+ * the submission attributes now on the button itself (#1307) the button needs
840
+ * nothing from the form, so the question stopped mattering and the whole
841
+ * asymmetry with SSR went with it.
842
+ *
843
+ * @param {Element} el
844
+ * @param {unknown} value
845
+ * @param {FormActionRecord} rec
846
+ * @param {boolean} emitsName whether SSR would emit a `name` attribute this pass
847
+ * @param {boolean} emitsValue whether SSR would emit a `value` attribute this pass
848
+ * @param {string | typeof ABSENT} authoredFormMethod what the template supplies for `formmethod`
849
+ * @param {string | typeof ABSENT} authoredFormEnctype what the template supplies for `formenctype`
850
+ */
851
+ function reconcileSubmitterAction(
852
+ el, value, rec, emitsName, emitsValue, authoredFormMethod, authoredFormEnctype,
853
+ ) {
854
+ const id = typeof value === 'function' ? formActionId(value) : null;
855
+ if (!id) {
856
+ // A function that was MEANT as an action still refuses, so a button never
857
+ // silently submits the form's action instead of its own.
858
+ if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
859
+ releaseSubmitterAction(el);
860
+ // The submission attributes the framework supplied go with the identity, or
861
+ // a released button keeps a `formmethod` / `formenctype` SSR does not emit
862
+ // for the same template (#1307).
863
+ releaseSubmitterAttrs(el, authoredFormMethod, authoredFormEnctype, rec.propAttrs);
864
+ return;
865
+ }
866
+ // Template-shaped refusals first, from the compiled record, because the live
867
+ // element may already carry this renderer's own `name` / `value`.
868
+ assertSingleSubmitterAction(rec.duplicateAction, el.localName);
869
+ assertConvergentSubmitter(rec.propAttrs, el.localName);
870
+ if (rec.staticAction) assertSubmitterHasNoStaticFormAction(el.localName);
871
+ if (rec.authoredValue || emitsValue) assertSubmitterHasNoValue(el.localName);
872
+ if (rec.authoredName || emitsName) {
873
+ // Judged on the PART, not on what it resolved to this pass. `name=${null}`
874
+ // leaves no attribute here while SSR emits `name=""` beside the identity,
875
+ // so reading the live value back returned '' and waved through a template
876
+ // SSR refuses. The template supplying a `name` channel at all is the
877
+ // conflict, whatever today's value happens to be.
878
+ assertSubmitterHasNoName(el.getAttribute('name') || FORM_ACTION_FIELD, el.localName, false);
879
+ }
880
+ if (rec.authoredForm || el.hasAttribute('form')) assertSubmitterHasNoFormAttribute(el.localName);
881
+ assertSubmitterType(el.localName, el.getAttribute('type'));
882
+
883
+ // The submission decision, made from the TEMPLATE rather than by reading the
884
+ // DOM back, exactly as `reconcileFormAction` makes it for a bound form. The
885
+ // distinction the DOM cannot preserve is the same one: `?formmethod=${false}`
886
+ // and `formmethod=${null}` both leave no attribute, and SSR resolves them to
887
+ // opposite answers.
888
+ const resolved = resolveBoundSubmitterAttrs(
889
+ el.localName, authoredFormMethod, authoredFormEnctype,
890
+ );
891
+ el.removeAttribute('formaction');
892
+ el.setAttribute('name', FORM_ACTION_FIELD);
893
+ el.setAttribute('value', id);
894
+ // AFTER the identity, matching SSR's byte order: the identity replaces the
895
+ // `formaction=` hole in place and the submission pair is appended at the `>`.
896
+ // The differential parity suite compares these two renderers byte for byte,
897
+ // so the order is part of the contract, not a detail.
898
+ applyResolvedAttr(el, 'formmethod', authoredFormMethod, resolved.formMethod);
899
+ applyResolvedAttr(el, 'formenctype', authoredFormEnctype, resolved.formEnctype);
900
+ submitterActionBindings.set(el, id);
498
901
  }
499
902
 
500
903
  /* ================================================================
@@ -506,7 +909,7 @@ function assignPaths(root, parts) {
506
909
  * @param {Element | DocumentFragment | ShadowRoot} container
507
910
  */
508
911
  function createInstance(tr, container) {
509
- const { templateEl, parts } = compile(tr);
912
+ const { templateEl, parts, formActions } = compile(tr);
510
913
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
511
914
 
512
915
  // Bookend markers bound the instance so we can tear it down cleanly.
@@ -519,6 +922,7 @@ function createInstance(tr, container) {
519
922
  applyPart(bound[i], tr.values[i], undefined, tr.values);
520
923
  lastValues.push(tr.values[i]);
521
924
  }
925
+ reconcileFormActions(formActions, bound, tr.values);
522
926
 
523
927
  /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
524
928
 
@@ -588,6 +992,13 @@ function bindPart(p, root) {
588
992
  throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
589
993
  }
590
994
 
995
+ /**
996
+ * Sentinel parked in `lastValues` for a hole whose commit threw, so the next
997
+ * render cannot mistake the un-advanced entry for "already applied". Never
998
+ * equal (by `Object.is`) to anything an author can pass through a template.
999
+ */
1000
+ const COMMIT_FAILED = Symbol('webjs.commitFailed');
1001
+
591
1002
  /**
592
1003
  * @param {TemplateInstance} inst
593
1004
  * @param {unknown[]} values
@@ -603,13 +1014,35 @@ function updateInstance(inst, values) {
603
1014
  // anchor hole's. Without this, a change confined to a non-anchor hole is
604
1015
  // dropped (the attribute goes stale).
605
1016
  const anchor = /** @type any */ (bp).mixedAnchor;
606
- if (bp.kind === 'noop' && anchor != null) {
607
- applyPart(inst.bound[anchor], values[anchor], inst.lastValues[anchor], values);
608
- } else {
609
- applyPart(bp, next, inst.lastValues[i], values);
1017
+ try {
1018
+ if (bp.kind === 'noop' && anchor != null) {
1019
+ applyPart(inst.bound[anchor], values[anchor], inst.lastValues[anchor], values);
1020
+ } else {
1021
+ applyPart(bp, next, inst.lastValues[i], values);
1022
+ }
1023
+ } catch (err) {
1024
+ // A commit that throws leaves `lastValues` for THIS hole un-advanced,
1025
+ // still holding the value from before the throw. That is almost always
1026
+ // the value the recovering render supplies, so the `Object.is` skip at
1027
+ // the top of this loop would skip the hole FOREVER. Harmless for an
1028
+ // attribute (its commit stringifies before touching the DOM, so nothing
1029
+ // changed), and permanently destructive for a child position, whose
1030
+ // commit tears the old content down BEFORE the step that throws: the
1031
+ // region is left empty and never re-rendered.
1032
+ //
1033
+ // Poison the entry with a sentinel no author value can ever be, so the
1034
+ // next render always re-applies this hole. The value is deliberately
1035
+ // NOT advanced to `next` either, since `next` was never committed.
1036
+ inst.lastValues[i] = COMMIT_FAILED;
1037
+ if (bp.kind === 'noop' && anchor != null) inst.lastValues[anchor] = COMMIT_FAILED;
1038
+ throw err;
610
1039
  }
611
1040
  inst.lastValues[i] = next;
612
1041
  }
1042
+ // Unconditional, and NOT inside the loop above: a form's correctness depends
1043
+ // on holes other than its own, and the `Object.is` skip means the action hole
1044
+ // may not have been re-applied at all this pass.
1045
+ reconcileFormActions(templateCache.get(inst.strings)?.formActions ?? null, inst.bound, values);
613
1046
  }
614
1047
 
615
1048
  /**
@@ -624,12 +1057,19 @@ function clearInstance(inst, container) {
624
1057
  for (const p of inst.bound) {
625
1058
  if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
626
1059
  if (p.kind === 'element') {
1060
+ // Guarded for the same reason as the sibling in `disposeInstance`, and
1061
+ // the stakes are higher here: this is the container-level teardown
1062
+ // `render()` runs before a template swap, so a throw skips the rest of
1063
+ // this loop AND the `replaceChildren()` below, leaving the old DOM in
1064
+ // place with `host[INSTANCE]` never reassigned. Since `lastTarget` is
1065
+ // cleared only after the write, every later swap of that container
1066
+ // then throws at the same part, permanently.
627
1067
  const prev = /** @type any */ (p).lastTarget;
628
1068
  if (prev) {
629
1069
  if (typeof prev === 'function') {
630
1070
  try { prev(undefined); } catch { /* swallow */ }
631
1071
  } else if (typeof prev === 'object') {
632
- prev.value = undefined;
1072
+ try { prev.value = undefined; } catch { /* swallow */ }
633
1073
  }
634
1074
  /** @type any */ (p).lastTarget = undefined;
635
1075
  /** @type any */ (p).__lastEl = undefined;
@@ -650,6 +1090,54 @@ function clearInstance(inst, container) {
650
1090
  * Part application
651
1091
  * ================================================================ */
652
1092
 
1093
+
1094
+ /**
1095
+ * Resolve what SSR would have emitted for one attribute of a candidate form.
1096
+ *
1097
+ * The per-kind rules mirror `render-server.js` exactly, which is the whole
1098
+ * point: a boolean hole emits nothing when falsy (`if (val) out += name+'=""'`)
1099
+ * while an attribute hole emits an EMPTY value for null (`String(val ?? '')`).
1100
+ * Those two produce the same DOM and opposite verdicts, so only the template
1101
+ * can tell them apart.
1102
+ *
1103
+ * @param {FormAttrPart[]} attrParts
1104
+ * @param {string | null} staticValue
1105
+ * @param {unknown[]} values
1106
+ * @returns {string | typeof ABSENT}
1107
+ */
1108
+ function effectiveFormAttr(attrParts, staticValue, values) {
1109
+ for (const p of attrParts) {
1110
+ if (p.kind === 'bool') return resolveHoleValue(values[p.i]) ? '' : ABSENT;
1111
+ if (p.kind === 'attr') {
1112
+ const v = resolveHoleValue(values[p.i]);
1113
+ return v == null ? '' : String(v);
1114
+ }
1115
+ // A mixed attribute is the concatenation of its static pieces and EVERY one
1116
+ // of its holes, so the anchor's own value is only part of the answer.
1117
+ const statics = p.statics || [];
1118
+ const group = p.group || [];
1119
+ let out = statics[0] || '';
1120
+ for (let j = 0; j < group.length; j++) {
1121
+ out += String(resolveHoleValue(values[group[j]]) ?? '');
1122
+ out += statics[j + 1] || '';
1123
+ }
1124
+ return out;
1125
+ }
1126
+ return staticValue == null ? ABSENT : staticValue;
1127
+ }
1128
+
1129
+ /**
1130
+ * Unwrap a hole's value the same way `applyPart` does, so the reconcile judges
1131
+ * what was actually committed. `live()` in particular wraps its value, and
1132
+ * reading the wrapper would make a bound action look like a plain object.
1133
+ *
1134
+ * @param {unknown} v
1135
+ * @returns {unknown}
1136
+ */
1137
+ function resolveHoleValue(v) {
1138
+ return isLive(v) ? /** @type any */ (v).value : v;
1139
+ }
1140
+
653
1141
  /**
654
1142
  * @param {BoundPart} part
655
1143
  * @param {unknown} value
@@ -672,7 +1160,18 @@ function applyPart(part, value, _prev, allValues) {
672
1160
  break;
673
1161
  case 'attr': {
674
1162
  if (value == null || value === false) part.el.removeAttribute(part.name);
675
- else {
1163
+ else if (isBoundFormAction(value, part.name, part.el.localName)) {
1164
+ // #1155: the ONE supported form-action binding, applied to the live
1165
+ // form exactly as SSR wrote it. A component that ships re-renders its
1166
+ // whole template on hydration, so without this the SSR'd hidden field
1167
+ // would be replaced by an `action` attribute holding a stringified
1168
+ // function, and the form would post to a garbage url.
1169
+ //
1170
+ // Nothing happens here. The whole decision (identity, the submit
1171
+ // attributes, the hidden field) is made at the end of the pass by
1172
+ // `reconcileFormAction`, because it depends on holes that have not
1173
+ // committed yet when this one does.
1174
+ } else {
676
1175
  // #1154: refuse to stringify a function into action=/formaction=
677
1176
  // (mirrors the SSR guard, so a client re-render cannot write a
678
1177
  // server action's source into the live DOM).
@@ -691,7 +1190,14 @@ function applyPart(part, value, _prev, allValues) {
691
1190
  // legitimate in both. The helper owns that distinction.
692
1191
  //
693
1192
  // Not covered here, because it is not a commit: a custom element's prop
694
- // declared `reflect: true` writes String(value) from its own setter.
1193
+ // declared `reflect: true` reflects from its own setter. That path used
1194
+ // to write String(value) and leak the source. #1169 made it remove the
1195
+ // attribute instead (an array carrying one included, via the same
1196
+ // `carriesFunction` predicate this file's guard uses), so it is guarded
1197
+ // at the setter rather than here. One carve-out stays the author's call:
1198
+ // a prop supplying its own `converter.toAttribute` runs that converter
1199
+ // first and is left alone, so it still writes whatever the author
1200
+ // returns for a function.
695
1201
  assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
696
1202
  /** @type any */ (part.el)[part.name] = value;
697
1203
  break;
@@ -983,10 +1489,17 @@ function applyChildInnerRaw(part, value) {
983
1489
  : Object.is(prevDeps, nextDeps);
984
1490
  if (equal) return;
985
1491
  }
986
- /** @type any */ (part).__guardDeps = Array.isArray(nextDeps)
987
- ? nextDeps.slice()
988
- : nextDeps;
1492
+ // Snapshot the deps BEFORE running `fn` (so a fn that mutates the array
1493
+ // it was handed cannot rewrite what we record), but RECORD them only
1494
+ // after the commit succeeds. Recording first meant a throw part-way
1495
+ // through the commit left the region torn down with the NEW deps already
1496
+ // stored, so every later render with the same deps hit the `equal`
1497
+ // short-circuit above and skipped the region forever. Renders are always
1498
+ // microtask-scheduled (`_scheduleUpdate`), so `fn()` cannot re-enter this
1499
+ // part synchronously and see the stale deps.
1500
+ const depsSnapshot = Array.isArray(nextDeps) ? nextDeps.slice() : nextDeps;
989
1501
  applyChildInner(part, v.fn());
1502
+ /** @type any */ (part).__guardDeps = depsSnapshot;
990
1503
  return;
991
1504
  }
992
1505
 
@@ -1119,7 +1632,7 @@ function applyChildInnerRaw(part, value) {
1119
1632
 
1120
1633
  if (isTemplate(value)) {
1121
1634
  const tr = /** @type any */ (value);
1122
- const { templateEl, parts } = compile(tr);
1635
+ const { templateEl, parts, formActions } = compile(tr);
1123
1636
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1124
1637
  const startNode = document.createComment(`${MARKER}s`);
1125
1638
  const endNode = document.createComment(`${MARKER}e`);
@@ -1129,6 +1642,7 @@ function applyChildInnerRaw(part, value) {
1129
1642
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1130
1643
  lastValues.push(tr.values[i]);
1131
1644
  }
1645
+ reconcileFormActions(formActions, bound, tr.values);
1132
1646
  const nodes = [startNode, ...frag.childNodes, endNode];
1133
1647
  marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
1134
1648
  // Slot parts in this nested template need their one-shot apply just
@@ -1155,16 +1669,46 @@ function nodesToFrag(nodes) {
1155
1669
  return frag;
1156
1670
  }
1157
1671
 
1158
- /** @param {Node} start @param {Node} end */
1672
+ /**
1673
+ * Remove a template instance's whole range, its bookend markers INCLUDED.
1674
+ *
1675
+ * Every caller discards the instance right after: the map entry or slot that
1676
+ * held it is dropped, and a replacement, where there is one, is built with
1677
+ * markers of its own. So this is a REMOVE, never lit's clear-and-reuse. A
1678
+ * caller that wants to keep the bookends and render into them again needs its
1679
+ * OWN function, because the two want opposite answers for the end marker.
1680
+ *
1681
+ * `parent` is read BEFORE the walk because the walk removes `start` on its
1682
+ * first iteration, which nulls `start.parentNode`. Reading it afterwards
1683
+ * compared the end marker's live parent against `null`, so the guard could
1684
+ * never fire and every teardown left one `wjm-e` comment in the document,
1685
+ * unbounded for the life of the region.
1686
+ *
1687
+ * The `end.parentNode === parent` comparison is a refusal, not a formality. A
1688
+ * marker moved under a different parent is not this region's to remove, and
1689
+ * `parent.removeChild(end)` on it throws NotFoundError from inside a teardown
1690
+ * that has to stay total.
1691
+ *
1692
+ * The walk assumes the range is INTACT: it steps `nextSibling` from `start`
1693
+ * and stops on `end`, so a range whose end no longer follows its start runs
1694
+ * off the child list and takes the part's own marker with it. That is not
1695
+ * something this function defends against, before or after the parent capture,
1696
+ * and the consequence is spelled out where it bites, on `reconcileRepeat`'s
1697
+ * catch below. The guard is the narrower promise: whatever the walk did, a
1698
+ * marker that is somewhere else is left alone.
1699
+ *
1700
+ * @param {Node} start @param {Node} end
1701
+ */
1159
1702
  function removeBetween(start, end) {
1160
- if (!start.parentNode) return;
1703
+ const parent = start.parentNode;
1704
+ if (!parent) return;
1161
1705
  let n = start;
1162
1706
  while (n && n !== end) {
1163
1707
  const next = n.nextSibling;
1164
1708
  n.parentNode?.removeChild(n);
1165
1709
  n = next;
1166
1710
  }
1167
- if (end.parentNode === start.parentNode) end.parentNode?.removeChild(end);
1711
+ if (end.parentNode === parent) parent.removeChild(end);
1168
1712
  }
1169
1713
 
1170
1714
  /* ================================================================
@@ -1178,7 +1722,7 @@ function removeBetween(start, end) {
1178
1722
  * @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
1179
1723
  */
1180
1724
  function buildDetached(tr) {
1181
- const { templateEl, parts } = compile(tr);
1725
+ const { templateEl, parts, formActions } = compile(tr);
1182
1726
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1183
1727
  const startNode = document.createComment(`${MARKER}s`);
1184
1728
  const endNode = document.createComment(`${MARKER}e`);
@@ -1188,6 +1732,7 @@ function buildDetached(tr) {
1188
1732
  applyPart(bound[i], tr.values[i], undefined, tr.values);
1189
1733
  lastValues.push(tr.values[i]);
1190
1734
  }
1735
+ reconcileFormActions(formActions, bound, tr.values);
1191
1736
  // Slot parts need their one-shot apply exactly like createInstance and the
1192
1737
  // nested-template path. The fragment is still detached here, so the
1193
1738
  // slot-part's own deferred finalize (a one-microtask retry when the parent
@@ -1216,12 +1761,33 @@ function disposeInstance(inst) {
1216
1761
  // Unbind any active ref so the user observes the element being
1217
1762
  // removed (callback receives undefined / Ref.value cleared).
1218
1763
  // Mirrors lit-html's cleanup-on-disconnect for element parts.
1764
+ //
1765
+ // BOTH branches swallow, and lit is not the reason: lit's ref directive
1766
+ // guards neither, so a throw there propagates. The reason is that a
1767
+ // teardown has to be TOTAL. `lastTarget` is cleared only AFTER these
1768
+ // writes, so a throw leaves the part still pointing at the ref and
1769
+ // every later teardown of the same instance throws at the same line
1770
+ // forever. It also aborts the rest of this loop, so the remaining
1771
+ // parts keep their listeners and their refs bound. A teardown has no
1772
+ // retry either (a commit has the COMMIT_FAILED sentinel and a next
1773
+ // render; this does not), so there is nothing a propagated error could
1774
+ // usefully repair.
1775
+ //
1776
+ // The object branch is the one this adds. The callback branch was
1777
+ // already guarded here AND on the commit path (`applyElement` wraps
1778
+ // every `nextTarget(...)` / `prevTarget(undefined)` call), so a
1779
+ // throwing ref CALLBACK has always been swallowed everywhere. What was
1780
+ // inconsistent is the object ref, guarded on neither. This makes the
1781
+ // two agree on TEARDOWN, which is where the totality argument bites.
1782
+ // It does NOT touch the commit path, so `applyElement`'s object-ref
1783
+ // writes still propagate to the component boundary, which has a route
1784
+ // for the error and a next render to repair it.
1219
1785
  const prev = /** @type any */ (p).lastTarget;
1220
1786
  if (prev) {
1221
1787
  if (typeof prev === 'function') {
1222
1788
  try { prev(undefined); } catch { /* swallow */ }
1223
1789
  } else if (typeof prev === 'object') {
1224
- prev.value = undefined;
1790
+ try { prev.value = undefined; } catch { /* swallow */ }
1225
1791
  }
1226
1792
  /** @type any */ (p).lastTarget = undefined;
1227
1793
  /** @type any */ (p).__lastEl = undefined;
@@ -1273,43 +1839,130 @@ function reconcileRepeat(part, value) {
1273
1839
 
1274
1840
  const newMap = new Map();
1275
1841
 
1276
- // Walk the new list and position each item's nodes immediately before the marker.
1277
- for (let i = 0; i < items.length; i++) {
1278
- const key = keyFn(items[i], i);
1279
- const tr = templateFn(items[i], i);
1280
- if (!isTemplate(tr)) continue;
1281
- const existing = state.map.get(key);
1282
- if (existing && existing.strings === /** @type any */ (tr).strings) {
1283
- updateInstance(existing, /** @type any */ (tr).values);
1284
- // Move nodes before marker preserving element identity.
1285
- moveRange(existing.startNode, existing.endNode, parent, marker);
1286
- newMap.set(key, existing);
1287
- state.map.delete(key);
1288
- } else {
1289
- if (existing) {
1290
- disposeInstance(existing);
1291
- removeBetween(existing.startNode, existing.endNode);
1842
+ try {
1843
+ // Walk the new list and position each item's nodes immediately before the marker.
1844
+ for (let i = 0; i < items.length; i++) {
1845
+ const key = keyFn(items[i], i);
1846
+ const tr = templateFn(items[i], i);
1847
+ if (!isTemplate(tr)) continue;
1848
+ const existing = state.map.get(key);
1849
+ if (existing && existing.strings === /** @type any */ (tr).strings) {
1850
+ updateInstance(existing, /** @type any */ (tr).values);
1851
+ // Move nodes before marker preserving element identity.
1852
+ moveRange(existing.startNode, existing.endNode, parent, marker);
1853
+ newMap.set(key, existing);
1292
1854
  state.map.delete(key);
1855
+ } else {
1856
+ if (existing) {
1857
+ // Unmapped BEFORE the row is touched, for the reason spelled out
1858
+ // in the leftover loop below: a key kept across a refused removal
1859
+ // points at a half-removed row, and reusing that row later walks
1860
+ // the removal off the end of the region. Same ordering, same
1861
+ // trade, and the two have to agree or the invariant the catch
1862
+ // relies on holds on one branch and not the other.
1863
+ state.map.delete(key);
1864
+ disposeInstance(existing);
1865
+ removeBetween(existing.startNode, existing.endNode);
1866
+ }
1867
+ const { inst, frag } = buildDetached(/** @type any */ (tr));
1868
+ parent.insertBefore(frag, marker);
1869
+ newMap.set(key, inst);
1293
1870
  }
1294
- const { inst, frag } = buildDetached(/** @type any */ (tr));
1295
- parent.insertBefore(frag, marker);
1296
- newMap.set(key, inst);
1297
1871
  }
1298
- }
1299
1872
 
1300
- // Remove any keys that remain in the old map.
1301
- for (const inst of state.map.values()) {
1302
- disposeInstance(inst);
1303
- removeBetween(inst.startNode, inst.endNode);
1873
+ // Remove any keys that remain in the old map. The key leaves the map
1874
+ // BEFORE its row is touched and the removal is in a `finally`, so at any
1875
+ // throw point `state.map` holds exactly the leftovers this pass has not
1876
+ // reached, and a row whose dispose threw still leaves the document.
1877
+ // Iterating a snapshot keeps the delete obviously safe rather than
1878
+ // relying on the reader knowing that deleting during a Map iteration is
1879
+ // legal.
1880
+ for (const [k, inst] of [...state.map]) {
1881
+ state.map.delete(k);
1882
+ try {
1883
+ disposeInstance(inst);
1884
+ } finally {
1885
+ removeBetween(inst.startNode, inst.endNode);
1886
+ }
1887
+ }
1888
+ state.map = newMap;
1889
+ } catch (err) {
1890
+ // The walk moves DOM and drains `state.map` incrementally while the
1891
+ // replacement map is accumulated locally, so a throw part-way through
1892
+ // leaves NEITHER map describing what is on screen: the already-processed
1893
+ // keys sit only in `newMap`, which is about to be discarded, while their
1894
+ // nodes stay in the document tracked by nothing. The next (perfectly
1895
+ // valid) render then rebuilds those keys from scratch and the orphaned
1896
+ // originals are never removed, so the list shows duplicated rows forever
1897
+ // with nothing logged after the first throw.
1898
+ //
1899
+ // Re-unite every instance still in the document under `state.map` (the
1900
+ // two maps are disjoint: a key lands in `newMap` only after being deleted
1901
+ // from `state.map`), so the map describes the DOM again and nothing is
1902
+ // orphaned. That is the whole repair. The next render is then an ORDINARY
1903
+ // reconcile against a truthful map, which repositions every row and
1904
+ // re-applies whatever the throw skipped.
1905
+ //
1906
+ // That claim covers the REMOVAL loop as well as the walk, and only
1907
+ // because the loop was written to earn it. It drops each key before
1908
+ // touching that row and removes the nodes in a `finally`, so a throw
1909
+ // mid-removal cannot merge `newMap` over a `state.map` still holding
1910
+ // disposed, detached rows. That was the failure: the row the app DELETED
1911
+ // stayed on screen, the survivors reordered, and a later render that
1912
+ // re-added that key reinserted the detached instance. The invariant, at
1913
+ // any throw point on either branch: every instance this pass has not
1914
+ // destructively touched is described by exactly one of the two maps,
1915
+ // `newMap` for the processed new keys and `state.map` for the leftovers
1916
+ // not reached yet, which is what makes the merge below correct. The
1917
+ // exception is the row named in the residual just below, whose removal
1918
+ // refused part-way; that one is in neither map, by choice.
1919
+ //
1920
+ // The residual is a throw from `removeBetween` ITSELF, which only calls
1921
+ // `removeChild` on nodes the renderer owns, so it takes a throwing DOM to
1922
+ // reach. That row is already unmapped, so its remaining nodes stay in the
1923
+ // document tracked by nothing and a later re-add of that key builds a
1924
+ // second row beside them. Unmapping AFTER the removal instead would keep
1925
+ // that key, and it is measurably worse rather than better: the row is
1926
+ // half removed, its start marker gone and its end marker still in place,
1927
+ // so the re-add hits the reuse branch and `moveRange` re-attaches the
1928
+ // lone start marker AFTER the end marker. The next removal of that key
1929
+ // then walks forward from a start that never reaches its end, taking the
1930
+ // repeat part's own marker and every following sibling with it, and the
1931
+ // region is dead for good. One untracked row beats a destroyed list.
1932
+ //
1933
+ // Deliberately NOT a teardown-and-rebuild of the region. Rebuilding is
1934
+ // the obvious defensive move and it is measurably worse: it discards node
1935
+ // identity for every row, which is the exact cost keyed reconciliation
1936
+ // exists to avoid (see the plain-array note below, a rebuild cancels an
1937
+ // in-progress native drag). It is also unnecessary, because this map is
1938
+ // restored to the truth rather than left describing a DOM that moved, so
1939
+ // there is nothing to guess and no partial DOM move to unwind.
1940
+ //
1941
+ // That is only half of it, and the other half does NOT live here: the row
1942
+ // whose own commit threw is repaired by the COMMIT_FAILED sentinel in
1943
+ // `updateInstance`, not by anything below. Without it the failed hole
1944
+ // compares EQUAL on the next render (its `lastValues` entry never
1945
+ // advanced past the throw, so it still holds the value that render
1946
+ // supplies) and is skipped forever, which for a child position means the
1947
+ // row stays permanently blank. Do not remove the sentinel on the theory
1948
+ // that a half-updated instance heals itself; it does not.
1949
+ for (const [k, inst] of newMap) state.map.set(k, inst);
1950
+ throw err;
1304
1951
  }
1305
- state.map = newMap;
1306
1952
  }
1307
1953
 
1308
1954
  /** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
1309
1955
  function teardownRepeat(state) {
1310
- for (const inst of state.map.values()) {
1311
- disposeInstance(inst);
1312
- removeBetween(inst.startNode, inst.endNode);
1956
+ // Same delete-as-you-go shape as the leftover loop in `reconcileRepeat`,
1957
+ // for the same reason: a throw part-way must not leave already-removed
1958
+ // instances in the map. The trailing `clear()` stays as a no-op safety net.
1959
+ for (const [k, inst] of [...state.map]) {
1960
+ state.map.delete(k);
1961
+ try {
1962
+ disposeInstance(inst);
1963
+ } finally {
1964
+ removeBetween(inst.startNode, inst.endNode);
1965
+ }
1313
1966
  }
1314
1967
  state.map.clear();
1315
1968
  }
@@ -1400,42 +2053,107 @@ function reconcileArray(part, value) {
1400
2053
  const old = state.items;
1401
2054
  /** @type {ArrayItem[]} */
1402
2055
  const next = [];
2056
+ // How many slots of `old` are fully processed. Tracked rather than inferred
2057
+ // from `next.length`, because the shrink loop below advances through `old`
2058
+ // while `next` stops growing, so the two part company there. The catch is
2059
+ // the only reader.
2060
+ let consumed = 0;
1403
2061
 
1404
- for (let i = 0; i < value.length; i++) {
1405
- const v = value[i];
1406
- const o = old[i];
1407
- if (isTemplate(v)) {
1408
- const tr = /** @type any */ (v);
1409
- if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1410
- updateInstance(o.inst, tr.values);
1411
- next.push(o);
1412
- continue;
1413
- }
1414
- } else if (v != null && v !== false && v !== true) {
1415
- if (o && o.type === 'text') {
1416
- const str = String(v);
1417
- if (o.node.data !== str) o.node.data = str;
1418
- next.push(o);
2062
+ try {
2063
+ for (let i = 0; i < value.length; i++) {
2064
+ const v = value[i];
2065
+ const o = old[i];
2066
+ if (isTemplate(v)) {
2067
+ const tr = /** @type any */ (v);
2068
+ if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
2069
+ updateInstance(o.inst, tr.values);
2070
+ next.push(o);
2071
+ consumed = i + 1;
2072
+ continue;
2073
+ }
2074
+ } else if (v != null && v !== false && v !== true) {
2075
+ if (o && o.type === 'text') {
2076
+ const str = String(v);
2077
+ if (o.node.data !== str) o.node.data = str;
2078
+ next.push(o);
2079
+ consumed = i + 1;
2080
+ continue;
2081
+ }
2082
+ } else {
2083
+ // Empty slot: drop any prior nodes that occupied this position.
2084
+ // Deliberately NOT reordered like the branch below. That reorder
2085
+ // exists to keep a slot that was already BUILT and INSERTED tracked,
2086
+ // and this branch builds and inserts nothing, so pushing first would
2087
+ // only mean a throw from the removal leaves a phantom empty slot at
2088
+ // this index AND `old[i]` spliced in at the next one, shifting every
2089
+ // later slot by one in a POSITIONAL reconciler. Removing first, a
2090
+ // throw here leaves `old[i]` describing its own position, which is
2091
+ // still exactly where its nodes are.
2092
+ if (o) removeArrayItem(o);
2093
+ next.push({ type: 'empty' });
2094
+ consumed = i + 1;
1419
2095
  continue;
1420
2096
  }
1421
- } else {
1422
- // Empty slot: drop any prior nodes that occupied this position.
2097
+ // Shape changed, or the array grew past the old length. Build fresh,
2098
+ // insert at this position (before the current / next still-attached
2099
+ // old node, else the marker), then drop the old slot it replaced.
2100
+ // The push sits BEFORE the removal, which is a pure reordering on the
2101
+ // success path and means a slot that has already been built and
2102
+ // inserted is never untracked at any throw point.
2103
+ const { item, frag } = buildArrayItem(v);
2104
+ if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
2105
+ next.push(item);
1423
2106
  if (o) removeArrayItem(o);
1424
- next.push({ type: 'empty' });
1425
- continue;
2107
+ consumed = i + 1;
1426
2108
  }
1427
- // Shape changed, or the array grew past the old length. Build fresh,
1428
- // insert at this position (before the current / next still-attached
1429
- // old node, else the marker), then drop the old slot it replaced.
1430
- const { item, frag } = buildArrayItem(v);
1431
- if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1432
- if (o) removeArrayItem(o);
1433
- next.push(item);
1434
- }
1435
2109
 
1436
- // Shrink: remove slots beyond the new length.
1437
- for (let i = value.length; i < old.length; i++) removeArrayItem(old[i]);
1438
- state.items = next;
2110
+ // Shrink: remove slots beyond the new length.
2111
+ for (let i = value.length; i < old.length; i++) {
2112
+ removeArrayItem(old[i]);
2113
+ consumed = i + 1;
2114
+ }
2115
+ state.items = next;
2116
+ } catch (err) {
2117
+ // `state.items` is committed only after the whole walk, so a throw part
2118
+ // way through discards `next` entirely: the map of slots keeps describing
2119
+ // positions whose nodes were already removed, while the freshly built and
2120
+ // inserted ones are in the document tracked by nothing. Nothing is logged
2121
+ // after the first throw, and the orphan outlives even a render of an EMPTY
2122
+ // array, because the only code that could remove it walks `state.items`.
2123
+ //
2124
+ // Splice the untouched tail of `old` onto what `next` accumulated, so the
2125
+ // bookkeeping describes the DOM again. The invariant that holds at any
2126
+ // throw point: every live node is described by exactly one slot, the
2127
+ // slots below `next.length` being the rebuilt or reused ones and the rest
2128
+ // the part of `old` this pass never reached. Index alignment survives
2129
+ // because this reconciler is POSITIONAL, so a slot's index IS its
2130
+ // identity, which is also why the boundary has to come from `consumed`
2131
+ // rather than `next.length`: during the shrink loop those differ, and
2132
+ // splicing from `next.length` would re-describe slots already removed.
2133
+ // A later render that grew the array would then match a live value
2134
+ // against a DETACHED slot with the same `strings`, update it in place,
2135
+ // and that row would silently never appear.
2136
+ //
2137
+ // Deliberately NOT a teardown-and-rebuild of the region, for the reason
2138
+ // recorded on `reconcileRepeat`'s catch above: it discards node identity
2139
+ // for every row, which cancels an in-progress native drag and drops focus
2140
+ // and scroll.
2141
+ //
2142
+ // The residual is a throw from the removal step itself, which takes a
2143
+ // throwing DOM to reach (the teardown it calls is total, so only
2144
+ // `removeBetween` is left, and that calls `removeChild` solely on nodes
2145
+ // the renderer owns). State it rather than deny it: `removeBetween`
2146
+ // takes the start marker first and then early-returns for good once that
2147
+ // marker is gone, so a row whose removal refused part-way can never be
2148
+ // removed afterwards. Its remaining nodes stay in the document, and an
2149
+ // EMPTY render will not clear them. Tracked or not, they are there for
2150
+ // the life of the region, the same residual `reconcileRepeat`'s catch
2151
+ // names. What this repair buys is that there is only ONE such row and
2152
+ // every other slot still reconciles, where before the whole pass was
2153
+ // discarded.
2154
+ state.items = next.concat(old.slice(consumed));
2155
+ throw err;
2156
+ }
1439
2157
  }
1440
2158
 
1441
2159
  /**
@@ -1716,6 +2434,9 @@ function applyUntil(part, args) {
1716
2434
  // a fresh TemplateResult on every call but the strings array is
1717
2435
  // interned per call site, so the conceptual value is unchanged.
1718
2436
  const partAny = /** @type any */ (part);
2437
+ // Same as applyWatch: the promise handlers below commit with no render on
2438
+ // the stack, so the owning component is recorded here while it is knowable.
2439
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1719
2440
  const prevState = partAny.__untilState;
1720
2441
  const prevArgs = partAny.__untilArgs;
1721
2442
  const argEq = (a, b) => {
@@ -1782,8 +2503,19 @@ function applyUntil(part, args) {
1782
2503
  (resolved) => {
1783
2504
  if (state.aborted) return;
1784
2505
  if (i >= state.highestResolved) return;
2506
+ // Commit FIRST, record the new priority only once it succeeded.
2507
+ // Advancing `highestResolved` up front meant a commit throw left the
2508
+ // region torn down while the state claimed index `i` had won, so
2509
+ // every later resolution at a lower priority was refused and the
2510
+ // region stayed empty. Committing first also keeps the throw inside
2511
+ // the try, where it can reach the component boundary.
2512
+ try {
2513
+ commitOutOfBand(part, () => applyChildInner(part, resolved));
2514
+ } catch (err) {
2515
+ reportOutOfBandCommitError(part, err);
2516
+ return;
2517
+ }
1785
2518
  state.highestResolved = i;
1786
- applyChildInner(part, resolved);
1787
2519
  },
1788
2520
  () => {
1789
2521
  // Swallow rejection. A rejected Promise is treated as "no value";
@@ -1793,6 +2525,54 @@ function applyUntil(part, args) {
1793
2525
  }
1794
2526
  }
1795
2527
 
2528
+ /**
2529
+ * Route a throw from an OUT-OF-BAND commit to the owning component's
2530
+ * render-error boundary.
2531
+ *
2532
+ * `watch`'s notify microtask and `until`'s Promise resolution commit from
2533
+ * outside `component.js`'s update cycle, so none of the boundaries that wrap
2534
+ * every other render path are on the stack. Left alone, a commit throw there
2535
+ * escapes as a window-level `error` / unhandled rejection instead of the
2536
+ * per-component `renderError()` the sync and async render paths both route
2537
+ * to, which breaks per-component error isolation for exactly these two
2538
+ * directives.
2539
+ *
2540
+ * Reads the owner stamped on the part when the directive was installed. See
2541
+ * the note in the body for why this is not a walk up the parent chain.
2542
+ *
2543
+ * @param {Extract<BoundPart, {kind:'child'}>} part
2544
+ * @param {unknown} error
2545
+ */
2546
+ function reportOutOfBandCommitError(part, error) {
2547
+ let err;
2548
+ try {
2549
+ err = error instanceof Error ? error : new Error(String(error));
2550
+ } catch {
2551
+ // The thrown value's own `toString` threw. That is the exact class of
2552
+ // value that makes a commit throw in the first place, so it is reachable
2553
+ // here; do not let stringifying it mask the original failure.
2554
+ err = new Error('render error (unstringifiable thrown value)');
2555
+ }
2556
+ // The owner stamped when the directive was installed, which is the
2557
+ // component whose TEMPLATE holds this part. Never walk the parent chain
2558
+ // for this: `_handleRenderError` lives on WebComponent's prototype, so
2559
+ // every upgraded element on the way up carries one, and the FIRST one a
2560
+ // structural walk meets is the innermost element the part happens to sit
2561
+ // inside, not the template that owns it. Routing there is not merely the
2562
+ // wrong log line: a light-DOM component's renderError() commits into the
2563
+ // component itself, which would replace the very children holding this
2564
+ // part's markers and silently kill every later update through it.
2565
+ const owner = /** @type any */ (part).__commitOwner;
2566
+ if (owner && typeof owner._handleRenderError === 'function') {
2567
+ owner._handleRenderError(err);
2568
+ return;
2569
+ }
2570
+ // No component boundary owns this part (a bare `render()` into a plain
2571
+ // container). Nothing can contain the error, so surface it rather than
2572
+ // swallow it.
2573
+ throw err;
2574
+ }
2575
+
1796
2576
  /**
1797
2577
  * Abort an `until` directive's in-flight Promise tracking. Called from
1798
2578
  * `teardownChild` when the part is being reset.
@@ -1824,6 +2604,11 @@ function teardownUntil(state) {
1824
2604
  */
1825
2605
  function applyWatch(part, sig) {
1826
2606
  const partAny = /** @type any */ (part);
2607
+ // Record the owning component while we are still inside its render(), the
2608
+ // only moment it is knowable. The notify microtask below commits with no
2609
+ // render on the stack and no way to derive it. Keep a prior stamp if this
2610
+ // somehow runs outside a render, rather than clearing a good owner.
2611
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1827
2612
  // Same signal as last render: refresh dep tracking via observe (the
1828
2613
  // spec-aligned Watcher fires once per arm, so re-observing re-arms).
1829
2614
  if (partAny.__watchSig === sig && partAny.__watchSub) {
@@ -1845,8 +2630,15 @@ function applyWatch(part, sig) {
1845
2630
  queueMicrotask(() => {
1846
2631
  if (partAny.__watchSub !== watcher) return; // disposed mid-flight
1847
2632
  let v;
1848
- watcher.observe(() => { v = sig.get(); });
1849
- applyChildInner(part, v);
2633
+ // Nothing above this microtask catches: it runs outside the host's
2634
+ // update cycle, so a commit throw here would surface at the window
2635
+ // instead of the component's renderError(). Route it explicitly.
2636
+ try {
2637
+ watcher.observe(() => { v = sig.get(); });
2638
+ commitOutOfBand(part, () => applyChildInner(part, v));
2639
+ } catch (err) {
2640
+ reportOutOfBandCommitError(part, err);
2641
+ }
1850
2642
  });
1851
2643
  });
1852
2644
  partAny.__watchSub = watcher;
@@ -1885,6 +2677,15 @@ function teardownWatch(partAny) {
1885
2677
  * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1886
2678
  */
1887
2679
  function applyAsyncAppend(part, dir) {
2680
+ const partAny = /** @type any */ (part);
2681
+ // Record the owning component while we are still inside its render(), the
2682
+ // only moment it is knowable, exactly as `applyWatch` / `applyUntil` do.
2683
+ // Chunks commit from an async loop with no render on the stack, so without
2684
+ // this both the chunk's own commit throw and any directive nested inside a
2685
+ // chunk have no owner to route to. Stamped ABOVE the short-circuit so a
2686
+ // re-render that returns early still refreshes the owner, and guarded so a
2687
+ // re-install outside a render keeps a previously good one.
2688
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1888
2689
  // Same-iterable short-circuit: if the prior render's iterable identity
1889
2690
  // matches, the existing iterator is still consuming it. Re-subscribing
1890
2691
  // would start a fresh iterator that misses already-yielded values.
@@ -1923,6 +2724,9 @@ function applyAsyncAppend(part, dir) {
1923
2724
  * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1924
2725
  */
1925
2726
  function applyAsyncReplace(part, dir) {
2727
+ const partAny = /** @type any */ (part);
2728
+ // Owner stamp: see comment in applyAsyncAppend. Above the short-circuit.
2729
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1926
2730
  // Same-iterable short-circuit: see comment in applyAsyncAppend.
1927
2731
  const currentChild = /** @type any */ (part.child);
1928
2732
  if (currentChild && currentChild.kind === 'async-stream'
@@ -1969,6 +2773,23 @@ function applyAsyncReplace(part, dir) {
1969
2773
  * after every `next()` resolve to short-circuit if abortion happened
1970
2774
  * while the iterator was suspended.
1971
2775
  *
2776
+ * Each pass carries TWO try spans, and which failure lands in which is the
2777
+ * load-bearing part. SPAN A is the author's own code, the iterable AND the
2778
+ * `mapper` it was given, and a throw from either is logged to the console and
2779
+ * ends the stream, on the long-standing reasoning that an author's iterable
2780
+ * should handle its own errors. SPAN B is the chunk COMMIT, which is a render
2781
+ * failure of the component whose template holds the binding, so it routes to
2782
+ * that component's `renderError()` and stops the stream.
2783
+ *
2784
+ * Scope note: only a throw from the COMMIT can stop the stream from here. A
2785
+ * directive nested INSIDE a committed chunk (a `watch` whose signal changes
2786
+ * later) throws from its own handler, outside this loop entirely, so it
2787
+ * reaches the boundary but this loop knows nothing about it and keeps
2788
+ * pulling. That is the same for any directive nested anywhere else. lit is no authority
2789
+ * either way here (it has no per-component boundary, and both failures become
2790
+ * unhandled rejections at the window), so this follows the
2791
+ * per-component error isolation WebJs has instead.
2792
+ *
1972
2793
  * @param {AsyncStreamState} state
1973
2794
  * @param {Extract<BoundPart, {kind:'child'}>} part
1974
2795
  * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
@@ -1976,39 +2797,84 @@ function applyAsyncReplace(part, dir) {
1976
2797
  async function consumeAsyncStream(state, part, dir) {
1977
2798
  const marker = part.marker;
1978
2799
  let i = 0;
1979
- try {
1980
- while (!state.aborted) {
1981
- const result = await state.iterator.next();
2800
+ while (!state.aborted) {
2801
+ /** @type {IteratorResult<unknown>} */
2802
+ let result;
2803
+ /** @type {unknown} */
2804
+ let mapped;
2805
+ // SPAN A, the author's iterable. A throw here is the author's generator
2806
+ // failing, not a render, so it keeps the long-standing console.error and
2807
+ // ends the stream. It is a separate span from the commit below on purpose
2808
+ // rather than a flag the one catch inspects, because the distinction is
2809
+ // the whole point: `reportOutOfBandCommitError` RETHROWS for a part with
2810
+ // no owner, and a single enclosing try would hand that rethrow straight
2811
+ // back to this swallow, which is the escape this split exists to stop.
2812
+ try {
2813
+ result = await state.iterator.next();
1982
2814
  if (state.aborted) break;
1983
2815
  if (result.done) break;
1984
- const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
1985
- const newNodes = renderToNodes(mapped);
1986
-
1987
- // This chunk commit runs in an async loop OUTSIDE any render() window,
1988
- // so open the renderer-write window explicitly: without it, committing a
1989
- // stream chunk into a light slot host would hit the patched insertBefore /
1990
- // removeChild and fold the renderer's own output into `authored`.
1991
- commitInto(marker.parentNode, () => {
1992
- if (state.mode === 'replace') {
1993
- for (const n of state.nodes) {
1994
- if (n.parentNode) n.parentNode.removeChild(n);
2816
+ mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
2817
+ } catch (err) {
2818
+ // Note this ENDS the stream, and always has: the catch used to sit
2819
+ // outside the loop, so there has never been a resume path here.
2820
+ if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
2821
+ return;
2822
+ }
2823
+
2824
+ // SPAN B, the chunk commit. This is a render of the component whose
2825
+ // TEMPLATE holds the binding, so a throw is that component's render
2826
+ // failure and routes to its `renderError()`, the same as `watch` and
2827
+ // `until` already do from their own out-of-band commits. `renderToNodes`
2828
+ // is INSIDE the wrap because that is where a nested directive is
2829
+ // installed and reads `currentRenderRoot`; without it, a `watch()` inside
2830
+ // a chunk is stamped with no owner and its later throw escapes.
2831
+ // `commitInto` is a different concern (the renderer-write window for a
2832
+ // light slot host), so the two nest rather than replace each other.
2833
+ try {
2834
+ commitOutOfBand(part, () => {
2835
+ const newNodes = renderToNodes(mapped);
2836
+
2837
+ // This chunk commit runs in an async loop OUTSIDE any render() window,
2838
+ // so open the renderer-write window explicitly: without it, committing a
2839
+ // stream chunk into a light slot host would hit the patched insertBefore /
2840
+ // removeChild and fold the renderer's own output into `authored`.
2841
+ commitInto(marker.parentNode, () => {
2842
+ if (state.mode === 'replace') {
2843
+ for (const n of state.nodes) {
2844
+ if (n.parentNode) n.parentNode.removeChild(n);
2845
+ }
2846
+ state.nodes = [];
1995
2847
  }
1996
- state.nodes = [];
1997
- }
1998
2848
 
1999
- const frag = document.createDocumentFragment();
2000
- for (const n of newNodes) frag.appendChild(n);
2001
- marker.parentNode?.insertBefore(frag, marker);
2002
- state.nodes.push(...newNodes);
2849
+ const frag = document.createDocumentFragment();
2850
+ for (const n of newNodes) frag.appendChild(n);
2851
+ marker.parentNode?.insertBefore(frag, marker);
2852
+ state.nodes.push(...newNodes);
2853
+ });
2003
2854
  });
2004
-
2005
- i++;
2855
+ } catch (err) {
2856
+ // Stop the stream. The boundary is about to render an error state, and
2857
+ // appending later chunks into a region it may have replaced is not a
2858
+ // recovery. The rendered nodes are left alone: blanking the region is a
2859
+ // separate decision, and `teardownAsyncStream` is for the part being
2860
+ // reset, not for this.
2861
+ state.aborted = true;
2862
+ try { state.iterator.return?.()?.catch?.(() => {}); } catch { /* best effort */ }
2863
+ // Rethrows when nothing can receive the error, which for a bare
2864
+ // `render()` into a plain container is an owner that carries no
2865
+ // `_handleRenderError` (the stamp records the container itself, so the
2866
+ // owner is present, just not a component). Surfacing beats swallowing
2867
+ // there, and it matches `watch` and `until`, which rethrow from their
2868
+ // own out-of-band handlers for the same reason. The exact shape differs
2869
+ // by site rather than being one thing: this rejects the loop's
2870
+ // promise, `until` rejects from its `.then`, and `watch` throws inside
2871
+ // a `queueMicrotask`, which is an uncaught error rather than a
2872
+ // rejection.
2873
+ reportOutOfBandCommitError(part, err);
2874
+ return;
2006
2875
  }
2007
- } catch (err) {
2008
- // Swallow iteration errors. A leaked iterator throwing should not
2009
- // crash the host's render cycle. Authors who care about errors
2010
- // should handle them in their iterable / generator.
2011
- if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
2876
+
2877
+ i++;
2012
2878
  }
2013
2879
  }
2014
2880
 
@@ -2022,12 +2888,13 @@ function renderToNodes(value) {
2022
2888
  if (value == null || value === false || value === true) return [];
2023
2889
  if (isTemplate(value)) {
2024
2890
  const tr = /** @type any */ (value);
2025
- const { templateEl, parts } = compile(tr);
2891
+ const { templateEl, parts, formActions } = compile(tr);
2026
2892
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
2027
2893
  const bound = parts.map((p) => bindPart(p, frag));
2028
2894
  for (let i = 0; i < tr.values.length; i++) {
2029
2895
  applyPart(bound[i], tr.values[i], undefined, tr.values);
2030
2896
  }
2897
+ reconcileFormActions(formActions, bound, tr.values);
2031
2898
  // Slot parts need their one-shot apply here too (same contract as
2032
2899
  // createInstance / nested templates / buildDetached): the caller
2033
2900
  // (consumeAsyncStream) inserts these nodes synchronously in the same