@webjsdev/core 0.7.47 → 0.7.49

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.
@@ -6,7 +6,7 @@ import {
6
6
  reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName,
7
7
  assertSubmitterType, assertSubmitterHasNoValue, assertSubmitterHasNoStaticFormAction,
8
8
  assertSubmitterHasNoFormAttribute, assertSingleSubmitterAction,
9
- assertSubmitterFormIsBound, assertSubmitterSubmission, assertBoundFormSubmitters,
9
+ resolveBoundSubmitterAttrs, applyResolvedAttr, releaseSubmitterAttrs,
10
10
  assertConvergentSubmitter, isSubmitterReflectedProp,
11
11
  formActionId, assertIdentifiableAction, FORM_ACTION_FIELD,
12
12
  } from './form-action.js';
@@ -104,14 +104,6 @@ function commitInto(node, fn) {
104
104
 
105
105
  /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[], formActions: FormActionRecord[] | null }>} */
106
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
107
  /**
116
108
  * Submitters this renderer stamped with an identity, so a later release removes
117
109
  * only the framework's own `name` / `value` and never an author's.
@@ -652,6 +644,11 @@ function assignPaths(root, parts) {
652
644
  function buildFormActionRecord(el, onEl, parts) {
653
645
  const isForm = el.localName === 'form';
654
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';
655
652
  const actionParts = onEl.filter((p) => p.kind === 'attr' && p.name.toLowerCase() === targetAttr);
656
653
  if (!actionParts.length) return null;
657
654
 
@@ -697,7 +694,11 @@ function buildFormActionRecord(el, onEl, parts) {
697
694
  }
698
695
  continue;
699
696
  }
700
- if (name !== 'method' && name !== 'enctype') continue;
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;
701
702
  if (p.kind !== 'attr' && p.kind !== 'attr-mixed' && p.kind !== 'bool') continue;
702
703
  const d = /** @type any */ (parts[p.idx]);
703
704
  /** @type {FormAttrPart} */
@@ -706,7 +707,14 @@ function buildFormActionRecord(el, onEl, parts) {
706
707
  // pieces have to travel with the record; reading the anchor's value alone
707
708
  // is right only when the statics are empty.
708
709
  if (p.kind === 'attr-mixed') { entry.statics = d.statics || []; entry.group = d.group || []; }
709
- (name === 'method' ? methodParts : enctypeParts).push(entry);
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);
710
718
  }
711
719
 
712
720
  return {
@@ -728,8 +736,8 @@ function buildFormActionRecord(el, onEl, parts) {
728
736
  propAttrs,
729
737
  nameParts,
730
738
  valueParts,
731
- staticMethod: el.getAttribute('method'),
732
- staticEnctype: el.getAttribute('enctype'),
739
+ staticMethod: el.getAttribute(methodAttr),
740
+ staticEnctype: el.getAttribute(enctypeAttr),
733
741
  methodParts,
734
742
  enctypeParts,
735
743
  };
@@ -754,8 +762,9 @@ function buildFormActionRecord(el, onEl, parts) {
754
762
  */
755
763
  function reconcileFormActions(formActions, bound, values) {
756
764
  if (!formActions) return;
757
- /** @type {HTMLFormElement[]} */
758
- const boundForms = [];
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.
759
768
  for (const pass of [true, false]) {
760
769
  for (const rec of formActions) {
761
770
  if (rec.isForm !== pass) continue;
@@ -779,9 +788,6 @@ function reconcileFormActions(formActions, bound, values) {
779
788
  effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
780
789
  rec,
781
790
  );
782
- if (typeof val === 'function' && formActionId(val)) {
783
- boundForms.push(/** @type any */ (part.el));
784
- }
785
791
  } else {
786
792
  // What SSR would have emitted for this pass. An attribute hole always
787
793
  // emits (even `name=${null}`, as `name=""`); a boolean hole emits only
@@ -791,14 +797,14 @@ function reconcileFormActions(formActions, bound, values) {
791
797
  ? !!resolveHoleValue(values[np.i])
792
798
  : true));
793
799
  reconcileSubmitterAction(
794
- /** @type any */ (part.el), val, rec, emits(rec.nameParts), emits(rec.valueParts),
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),
795
804
  );
796
805
  }
797
806
  }
798
807
  }
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
808
  }
803
809
 
804
810
  /**
@@ -820,65 +826,47 @@ function releaseSubmitterAction(el) {
820
826
  submitterActionBindings.delete(el);
821
827
  }
822
828
 
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
829
  /**
847
830
  * 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.
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`.
850
834
  *
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.
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.
862
842
  *
863
843
  * @param {Element} el
864
844
  * @param {unknown} value
865
845
  * @param {FormActionRecord} rec
866
846
  * @param {boolean} emitsName whether SSR would emit a `name` attribute this pass
867
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`
868
850
  */
869
- function reconcileSubmitterAction(el, value, rec, emitsName, emitsValue) {
851
+ function reconcileSubmitterAction(
852
+ el, value, rec, emitsName, emitsValue, authoredFormMethod, authoredFormEnctype,
853
+ ) {
870
854
  const id = typeof value === 'function' ? formActionId(value) : null;
871
855
  if (!id) {
872
856
  // A function that was MEANT as an action still refuses, so a button never
873
857
  // silently submits the form's action instead of its own.
874
858
  if (typeof value === 'function') assertIdentifiableAction(null, el.localName);
875
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);
876
864
  return;
877
865
  }
878
866
  // Template-shaped refusals first, from the compiled record, because the live
879
867
  // element may already carry this renderer's own `name` / `value`.
880
868
  assertSingleSubmitterAction(rec.duplicateAction, el.localName);
881
- assertConvergentSubmitter(rec.propAttrs, el.localName, true);
869
+ assertConvergentSubmitter(rec.propAttrs, el.localName);
882
870
  if (rec.staticAction) assertSubmitterHasNoStaticFormAction(el.localName);
883
871
  if (rec.authoredValue || emitsValue) assertSubmitterHasNoValue(el.localName);
884
872
  if (rec.authoredName || emitsName) {
@@ -892,32 +880,23 @@ function reconcileSubmitterAction(el, value, rec, emitsName, emitsValue) {
892
880
  if (rec.authoredForm || el.hasAttribute('form')) assertSubmitterHasNoFormAttribute(el.localName);
893
881
  assertSubmitterType(el.localName, el.getAttribute('type'));
894
882
 
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 },
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,
917
890
  );
918
891
  el.removeAttribute('formaction');
919
892
  el.setAttribute('name', FORM_ACTION_FIELD);
920
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);
921
900
  submitterActionBindings.set(el, id);
922
901
  }
923
902
 
@@ -1180,17 +1159,6 @@ function applyPart(part, value, _prev, allValues) {
1180
1159
  applyChild(part, value);
1181
1160
  break;
1182
1161
  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
- }
1194
1162
  if (value == null || value === false) part.el.removeAttribute(part.name);
1195
1163
  else if (isBoundFormAction(value, part.name, part.el.localName)) {
1196
1164
  // #1155: the ONE supported form-action binding, applied to the live
@@ -1701,16 +1669,46 @@ function nodesToFrag(nodes) {
1701
1669
  return frag;
1702
1670
  }
1703
1671
 
1704
- /** @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
+ */
1705
1702
  function removeBetween(start, end) {
1706
- if (!start.parentNode) return;
1703
+ const parent = start.parentNode;
1704
+ if (!parent) return;
1707
1705
  let n = start;
1708
1706
  while (n && n !== end) {
1709
1707
  const next = n.nextSibling;
1710
1708
  n.parentNode?.removeChild(n);
1711
1709
  n = next;
1712
1710
  }
1713
- if (end.parentNode === start.parentNode) end.parentNode?.removeChild(end);
1711
+ if (end.parentNode === parent) parent.removeChild(end);
1714
1712
  }
1715
1713
 
1716
1714
  /* ================================================================