@webjsdev/core 0.7.47 → 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.
@@ -43,30 +43,43 @@ import { escapeAttr } from './escape.js';
43
43
  * needed on the JS path either: `new FormData(form, submitter)` already
44
44
  * includes the pressed button's pair.
45
45
  *
46
+ * THE RULE, one sentence, and the whole reason this file is shaped the way it
47
+ * is (#1307): the renderer supplies submission attributes at the level where
48
+ * the action is BOUND, and never overrides what the author wrote at that same
49
+ * level. A bound `<form>` gains `method="post"` and `enctype`; a bound
50
+ * `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON. So a
51
+ * bound submitter is SELF-SUFFICIENT: it works inside a bound form, an unbound
52
+ * form, a `method="get"` form, or a form with no method at all, and it asks
53
+ * nothing of the element around it.
54
+ *
55
+ * That is what React does (`pushFormActionAttribute` emits `formMethod` and
56
+ * `formEncType` beside the identity, from an action descriptor that is
57
+ * literally `{name, method: 'POST', encType: 'multipart/form-data'}`), and it
58
+ * is what makes the rule below possible.
59
+ *
46
60
  * Refused shapes on a submitter, each because the identity would not arrive:
47
61
  * - Its own `name` (static `name="..."` or a `name=${n}` hole) or its own
48
62
  * `value`, because the identity occupies both halves of that one pair.
49
- * - An enclosing `<form>` that is not itself bound, because `method="post"`
50
- * and the enctype are forced on the FORM's start tag, which SSR has already
51
- * emitted by the time it reaches the button. Refused only where the
52
- * renderer can SEE that form: see `assertSubmitterFormIsBound`.
53
63
  * - `<input type="image">`, which submits `name.x` / `name.y` coordinates
54
64
  * instead of `name=value`, so the identity would never arrive.
55
65
  * - A control that is not a submitter at all, where `formaction` is inert.
56
- * - A `form="other"` attribute, which moves the submitter's form owner out
57
- * from under the boundness the renderers just checked.
58
- *
59
- * Part B of #1207, and the reason it is not scoped to a bound submitter: a
60
- * submitter's `formmethod` / `formenctype` can defeat a bound form whether or
61
- * not that button binds its own action, and neither renderer used to see them.
62
- * `<form action=${fn}><button formenctype="text/plain">` submits fine under JS
63
- * (the router posts FormData, ignoring `formenctype`) and is a bare 405 without
64
- * it, which is precisely the works-one-way-only near-miss refused everywhere
65
- * else here. So `assertSubmitterSubmission` runs for EVERY submitter inside a
66
- * bound form. Two carve-outs keep it from over-refusing: `formmethod="dialog"`
67
- * is a native `<dialog>` dismissal that never submits, and a submitter with its
68
- * own static `formaction="/url"` retargets away from the bound action entirely,
69
- * so a GET there is the author's business.
66
+ * - A `form="other"` attribute, which moves the submitter's form owner
67
+ * somewhere the identity field it needs may not be.
68
+ *
69
+ * WHAT IS NOT REFUSED, and the line that decides it: only a SAME-ELEMENT
70
+ * contradiction refuses, never a rule about the author's OTHER elements. A
71
+ * same-element contradiction has no correct behaviour to fall back to (a bound
72
+ * button that also says `formmethod="get"` cannot both run the action and send
73
+ * no body). A cross-element rule always does have one, namely whatever native
74
+ * HTML would have done, so the renderer honours it instead of refusing.
75
+ *
76
+ * The concrete consequence, and it is a deliberate reversal of #1207's Part B:
77
+ * a PLAIN, non-binding `<button formmethod="get">` inside a bound form now
78
+ * renders untouched. Native HTML says the submitter's override wins, an author
79
+ * who typed it meant it, and the form's action simply does not run, which is
80
+ * exactly what the same markup does in any other framework. The dev-time client
81
+ * guard reports at submit time when a submission carries an identity it cannot
82
+ * deliver, which is the right place for a judgement call about intent.
70
83
  *
71
84
  * Both SSR state machines and the client renderer import from here so the
72
85
  * rules cannot drift between them. Each renderer commits attribute, boolean
@@ -351,38 +364,6 @@ export function assertSingleSubmitterAction(duplicate, tag) {
351
364
  );
352
365
  }
353
366
 
354
- /**
355
- * Refuse a `formaction=${fn}` submitter whose enclosing form is not itself
356
- * bound.
357
- *
358
- * `method="post"` and the enctype are forced on the FORM's start tag, which SSR
359
- * has already emitted by the time it reaches the button, so a per-button action
360
- * cannot retrofit them. An unbound form defaults to GET, which sends no body,
361
- * so the identity would ride the query string and the action would never run.
362
- *
363
- * BEST EFFORT in BOTH renderers, which is the honest statement and replaces an
364
- * earlier claim here that SSR always knows. It does not: a component renders its
365
- * own template in a separate pass seeded `'unknown'`, with no view of the host
366
- * page, and buttons usually live in components. The client is best effort for
367
- * its own reason (a submitter in a nested template cannot always reach its form
368
- * while the fragment is detached). Each renderer refuses only where its answer
369
- * is genuinely known, and binds where it is not, because refusing on
370
- * cannot-tell rejects ordinary shapes and, at SSR, silently drops an isolated
371
- * component from a page that still returns 200.
372
- *
373
- * @param {boolean} insideBoundForm
374
- * @param {string} [tag]
375
- */
376
- export function assertSubmitterFormIsBound(insideBoundForm, tag) {
377
- if (insideBoundForm) return;
378
- throw new Error(
379
- `[webjs] formaction=\${action} on <${tag || 'button'}> requires the enclosing `
380
- + `<form> to also be bound to a server action (e.g. <form action=\${formAction}>). `
381
- + `The enclosing <form> must carry an action binding so method="post" and the `
382
- + `enctype are set at form start, which is too late to add from the button.`,
383
- );
384
- }
385
-
386
367
  /**
387
368
  * The submitter twin of `assertConvergentBoundForm`: refuse a `.prop` spelling
388
369
  * on a submitter that the two renderers can never agree on.
@@ -397,29 +378,20 @@ export function assertSubmitterFormIsBound(insideBoundForm, tag) {
397
378
  * That is the render-on-the-server, crash-on-hydration direction, which is the
398
379
  * one failure this module treats as unacceptable.
399
380
  *
400
- * `formMethod` / `formEnctype` are refused on ANY submitter inside a bound
401
- * form, not only a bound one, for the same reason Part B is: the browser write
402
- * reflects, so the attribute the server never saw is the one that decides
403
- * whether the submission can carry the action's body.
381
+ * Scoped to a BOUND submitter (#1307). A plain `<button .name=${'intent'}>`
382
+ * inside a bound form is the ordinary one-action-plus-intent-dispatch pattern
383
+ * and is left alone, and a plain button's `.formMethod` / `.formEnctype` is now
384
+ * an ordinary native property on an ordinary button, since its own override no
385
+ * longer contradicts anything the renderer promised. The native-property rule
386
+ * (SSR drops a `.prop`, the browser reflects it) is what this codebase already
387
+ * accepts everywhere else, including for an unbound form's `.method`.
404
388
  *
405
389
  * @param {string[] | undefined} propAttrs attribute names a property part owns
406
390
  * @param {string} tag lowercased owner tag
407
- * @param {boolean} [bound] whether this submitter carries its own action binding
408
391
  * @returns {void}
409
392
  */
410
- export function assertConvergentSubmitter(propAttrs, tag, bound) {
411
- // On a submitter that binds nothing, `.name` / `.value` are not this module's
412
- // business: a plain `<button .name=${'intent'}>` inside a bound form is the
413
- // ordinary one-action-plus-intent-dispatch pattern, and refusing it would
414
- // reject valid markup. The THREE that can still defeat the enclosing binding
415
- // are judged there. `formAction` belongs in that set as surely as the other
416
- // two: SSR drops the prop so the button submits to the page and runs the
417
- // bound action, while a browser reflects it and the button posts somewhere
418
- // else entirely. (A STATIC `formaction="/url"` stays fine, because both
419
- // renderers see it and agree; only the property spelling diverges.)
420
- const relevant = (propAttrs || []).filter((n) => (bound
421
- ? isSubmitterReflectedProp(n)
422
- : /^form(action|method|enctype)$/i.test(String(n))));
393
+ export function assertConvergentSubmitter(propAttrs, tag) {
394
+ const relevant = (propAttrs || []).filter((n) => isSubmitterReflectedProp(n));
423
395
  const prop = relevant[0];
424
396
  if (!prop) return;
425
397
  throw new Error(
@@ -444,47 +416,38 @@ export function isSubmitterReflectedProp(name) {
444
416
  }
445
417
 
446
418
  /**
447
- * Part B of #1207: refuse a submitter whose own `formmethod` / `formenctype`
448
- * would defeat the bound form it sits in.
449
- *
450
- * Runs for EVERY submitter inside a bound form, not only one that binds its own
451
- * action, because that is where the gap was: `<form action=${fn}><button
452
- * formenctype="text/plain">` submits fine under JS (the router posts FormData
453
- * and ignores `formenctype`) and is a bare 405 without it, since the server
454
- * parses a submission as multipart or urlencoded only. Refusing the VALUES that
455
- * cannot work, never the attributes themselves, so `formenctype` naming either
456
- * parseable encoding stays fully supported.
457
- *
458
- * Two carve-outs, both cases where the submitter is not submitting to the bound
459
- * action at all:
460
- * - `formmethod="dialog"` is a native `<dialog>` dismissal, never a
461
- * submission, so there is no body for the action to miss. Refused only when
462
- * the same button also BINDS an action, which is a straight contradiction.
463
- * - A static `formaction="/url"` retargets the submission away from the
464
- * page's bound action entirely, so a GET there is an ordinary form the
465
- * author is entitled to write.
419
+ * The submitter twin of `assertSubmittableForm`: refuse a BOUND submitter whose
420
+ * own `formmethod` / `formenctype` contradicts the action it binds.
421
+ *
422
+ * Only a BOUND submitter reaches here (#1307), which is the reversal of #1207's
423
+ * Part B. A plain `<button formmethod="get">` inside a bound form is a legal
424
+ * native override that means exactly what the author typed, so it renders
425
+ * untouched and the form's action simply does not run. Refusing it would be a
426
+ * rule about the author's OTHER element, and native HTML already defines the
427
+ * outcome. The dev-time client guard reports at submit time when a submission
428
+ * holds an identity it cannot deliver.
429
+ *
430
+ * On a bound submitter both values ARE same-element contradictions, because the
431
+ * renderer would otherwise be supplying `formmethod="post"` and an enctype onto
432
+ * the very element the author just told to do something else. Refusing the
433
+ * VALUES that cannot work, never the attributes themselves, so `formenctype`
434
+ * naming either parseable encoding stays fully supported.
466
435
  *
467
436
  * @param {string} tag lowercased owner tag, for the message
468
437
  * @param {string | null | undefined} formMethod the submitter's `formmethod`, or null
469
438
  * @param {string | null | undefined} formEnctype the submitter's `formenctype`, or null
470
- * @param {{ bound?: boolean, retargeted?: boolean }} [opts]
471
439
  */
472
- export function assertSubmitterSubmission(tag, formMethod, formEnctype, opts) {
473
- const bound = !!(opts && opts.bound);
474
- const retargeted = !!(opts && opts.retargeted);
440
+ export function assertSubmittableSubmitter(tag, formMethod, formEnctype) {
475
441
  const method = formMethod == null ? null : String(formMethod);
476
442
  const enctype = formEnctype == null ? null : String(formEnctype);
477
- const isDialog = method != null && /^dialog$/i.test(method);
478
443
 
479
- if (isDialog) {
480
- if (!bound) return;
444
+ if (method != null && /^dialog$/i.test(method)) {
481
445
  throw new Error(
482
446
  `[webjs] formaction=\${action} on <${tag}> cannot be combined with `
483
447
  + `formmethod="dialog", which dismisses a <dialog> instead of submitting, `
484
448
  + `so the bound action would never run. Drop one of the two.`,
485
449
  );
486
450
  }
487
- if (retargeted) return;
488
451
 
489
452
  // Compared UNTRIMMED, exactly as `assertSubmittableForm` compares a form's
490
453
  // own `method`. These are enumerated attributes a browser matches against
@@ -494,20 +457,20 @@ export function assertSubmitterSubmission(tag, formMethod, formEnctype, opts) {
494
457
  // silently-posts-nowhere submitter this refusal exists to make impossible.
495
458
  if (method != null && !/^post$/i.test(method)) {
496
459
  throw new Error(
497
- `[webjs] <${tag} formmethod="${method}"> inside a bound <form action=\${action}> `
498
- + `cannot work: a bound server action is submitted as a POST body, and a `
499
- + `"${method}" submission sends none, so the action would never run. Drop the `
500
- + `formmethod attribute, or point the button elsewhere with a plain `
501
- + `formaction="/url".`,
460
+ `[webjs] <${tag} formmethod="${method}" formaction=\${action}> cannot work: a `
461
+ + `bound server action is submitted as a POST body, and a "${method}" `
462
+ + `submission sends none, so the action would never run. Drop the formmethod `
463
+ + `attribute (WebJs emits formmethod="post" on a bound submitter), or drop the `
464
+ + `binding.`,
502
465
  );
503
466
  }
504
467
  if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) {
505
468
  throw new Error(
506
- `[webjs] <${tag} formenctype="${enctype}"> inside a bound <form action=\${action}> `
507
- + `cannot work: a form submission is parsed as multipart/form-data or `
508
- + `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so `
509
- + `the submission would run under JS and 405 without it. Drop the formenctype `
510
- + `attribute (WebJs emits multipart/form-data on the form).`,
469
+ `[webjs] <${tag} formenctype="${enctype}" formaction=\${action}> cannot work: a `
470
+ + `form submission is parsed as multipart/form-data or `
471
+ + `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so the `
472
+ + `bound action could never read it. Drop the formenctype attribute (WebJs emits `
473
+ + `multipart/form-data on a bound submitter).`,
511
474
  );
512
475
  }
513
476
  }
@@ -789,6 +752,87 @@ export function resolveBoundFormAttrs(method, enctype) {
789
752
  };
790
753
  }
791
754
 
755
+ /**
756
+ * THE decision both renderers make about a bound SUBMITTER, in one place, the
757
+ * twin of `resolveBoundFormAttrs` (#1307).
758
+ *
759
+ * Sharing it is the point, for the same reason the form version is shared: SSR
760
+ * reaches it with the attributes parsed off the start tag it just emitted, the
761
+ * client reaches it with the values its template would have emitted, and the
762
+ * predicate is literally the same function.
763
+ *
764
+ * Returns what to INJECT for each attribute, or null to leave the author's own
765
+ * value alone. Throws when a value cannot submit at all.
766
+ *
767
+ * @param {string} tag lowercased owner tag, for the message
768
+ * @param {string | typeof ABSENT | null} formMethod
769
+ * @param {string | typeof ABSENT | null} formEnctype
770
+ * @returns {{ formMethod: string | null, formEnctype: string | null }}
771
+ */
772
+ export function resolveBoundSubmitterAttrs(tag, formMethod, formEnctype) {
773
+ const hasMethod = formMethod !== ABSENT && formMethod != null;
774
+ const hasEnctype = formEnctype !== ABSENT && formEnctype != null;
775
+ assertSubmittableSubmitter(
776
+ tag,
777
+ hasMethod ? /** @type string */ (formMethod) : null,
778
+ hasEnctype ? /** @type string */ (formEnctype) : null,
779
+ );
780
+ return {
781
+ formMethod: hasMethod ? null : 'post',
782
+ formEnctype: hasEnctype ? null : 'multipart/form-data',
783
+ };
784
+ }
785
+
786
+ /**
787
+ * Rewrite a bound submitter's START TAG (#1307), the twin of
788
+ * `bindFormActionStartTag`.
789
+ *
790
+ * The identity was already written at the hole, in place of the `formaction=`
791
+ * the author spelled. What is added here is the REST of the submission, because
792
+ * only at the `>` is the whole start tag known and an attribute the author wrote
793
+ * AFTER the binding still counts:
794
+ * - `formmethod="post"` when absent, so the submission carries a body whatever
795
+ * the enclosing form declares, or fails to declare.
796
+ * - `formenctype="multipart/form-data"` when absent, so a file input works on
797
+ * the no-JS path.
798
+ *
799
+ * That pair is what makes the button self-sufficient, and it is the whole fix
800
+ * for #1307: the renderer no longer has to know anything about the enclosing
801
+ * form, so the cannot-tell case it could never resolve stops existing.
802
+ *
803
+ * No `formaction` is emitted. An empty url is an HTML conformance error, which
804
+ * is the same reason `bindFormActionStartTag` omits `action` rather than writing
805
+ * `action=""`. React does emit `formAction=""` here; WebJs deliberately does
806
+ * not. The consequence, stated so nobody rediscovers it: a bound submitter
807
+ * inside a form that declares its own `action="/x"` submits to `/x`. That is
808
+ * native precedence honoured, the author supplied a target at the form level and
809
+ * none on the button, and the action still RUNS there because the identity
810
+ * travels in the body. Leaving the form's `action` off, the ordinary shape,
811
+ * keeps the submission on the current page.
812
+ *
813
+ * @param {string} startTag the emitted start tag, ending in `>`
814
+ * @param {string} tag lowercased owner tag
815
+ * @param {{ duplicateAction?: boolean, propAttrs?: string[] }} shape
816
+ * @returns {string} the rewritten start tag
817
+ */
818
+ export function bindSubmitterStartTag(startTag, tag, shape) {
819
+ assertSubmitterStartTag(startTag, tag, shape);
820
+ const attrs = parseStartTagAttrs(startTag);
821
+ const resolved = resolveBoundSubmitterAttrs(
822
+ tag,
823
+ attrs.has('formmethod') ? attrs.get('formmethod') : ABSENT,
824
+ attrs.has('formenctype') ? attrs.get('formenctype') : ABSENT,
825
+ );
826
+ // The close is `>` or `/>`; `<input>` is void and may be written either way,
827
+ // so keep whichever the author wrote.
828
+ const close = startTag.endsWith('/>') ? '/>' : '>';
829
+ const head = startTag.slice(0, startTag.length - close.length);
830
+ let inject = '';
831
+ if (resolved.formMethod !== null) inject += ` formmethod="${resolved.formMethod}"`;
832
+ if (resolved.formEnctype !== null) inject += ` formenctype="${resolved.formEnctype}"`;
833
+ return head + inject + close;
834
+ }
835
+
792
836
  /**
793
837
  * The hidden field carrying a bound action's identity into the submission.
794
838
  * @param {string} id
@@ -863,18 +907,21 @@ export function reconcileFormAction(form, value, method, enctype, shape) {
863
907
  * the element, put there by whichever commit branch owns that hole, and is left
864
908
  * exactly as written.
865
909
  *
866
- * @param {Element} form
910
+ * Element-agnostic on purpose: a bound `<form>` reaches it for `method` /
911
+ * `enctype` and a bound submitter for `formmethod` / `formenctype` (#1307).
912
+ *
913
+ * @param {Element} el
867
914
  * @param {string} name
868
915
  * @param {string | typeof ABSENT} authored
869
916
  * @param {string | null} inject
870
917
  */
871
- function applyResolvedAttr(form, name, authored, inject) {
872
- if (inject !== null) { form.setAttribute(name, inject); return; }
918
+ export function applyResolvedAttr(el, name, authored, inject) {
919
+ if (inject !== null) { el.setAttribute(name, inject); return; }
873
920
  // The author's value is authoritative. It is already committed, except for a
874
921
  // hole whose value is empty, which some branches express by removing the
875
922
  // attribute; put it back so the DOM matches what SSR emitted.
876
- if (authored !== ABSENT && form.getAttribute(name) !== authored) {
877
- form.setAttribute(name, /** @type string */ (authored));
923
+ if (authored !== ABSENT && el.getAttribute(name) !== authored) {
924
+ el.setAttribute(name, /** @type string */ (authored));
878
925
  }
879
926
  }
880
927
 
@@ -926,11 +973,11 @@ function ensureIdentityField(form, id) {
926
973
  * because every input is an attribute the author may have written AFTER the
927
974
  * binding.
928
975
  *
929
- * Two callers, distinguished by `bound`:
930
- * - `bound: true`, the tag carried a `formaction=${fn}` hole, so the full
931
- * refusal set applies.
932
- * - `bound: false`, an ordinary submitter inside a bound form, which is the
933
- * Part B sweep and only looks at `formmethod` / `formenctype`.
976
+ * ONE caller shape since #1307: the tag carried a `formaction=${fn}` hole, so
977
+ * the full refusal set applies. The old `bound: false` sweep over every
978
+ * ordinary submitter inside a bound form is gone with Part B, because a plain
979
+ * button's own `formmethod` / `formenctype` is now a legal native override
980
+ * rather than a near-miss.
934
981
  *
935
982
  * `name` and `value` are judged by COUNT, not by value, because the renderer
936
983
  * has already injected its own pair by this point and a browser resolves a
@@ -938,101 +985,29 @@ function ensureIdentityField(form, id) {
938
985
  *
939
986
  * @param {string} startTag the emitted start tag, ending in `>`
940
987
  * @param {string} tag lowercased owner tag
941
- * @param {{ bound: boolean, duplicateAction?: boolean, propAttrs?: string[] }} shape
988
+ * @param {{ duplicateAction?: boolean, propAttrs?: string[] }} shape
942
989
  * @returns {void}
943
990
  */
944
991
  export function assertSubmitterStartTag(startTag, tag, shape) {
945
- const bound = !!shape.bound;
946
992
  const attrs = parseStartTagAttrs(startTag);
947
993
  const type = attrs.has('type') ? attrs.get('type') : null;
948
994
 
949
- // Gated on being a real submitter, because these properties reflect on any
950
- // control: `<input .name=${'q'}>` is an ordinary field inside the form and
951
- // has nothing to do with the action.
952
- if (bound || isSubmitterType(tag, type)) assertConvergentSubmitter(shape.propAttrs, tag, bound);
953
-
954
- if (bound) {
955
- assertSingleSubmitterAction(!!shape.duplicateAction, tag);
956
- if (attrs.has('formaction')) assertSubmitterHasNoStaticFormAction(tag);
957
- if (countStartTagAttr(startTag, 'value') > 1) assertSubmitterHasNoValue(tag);
958
- if (countStartTagAttr(startTag, 'name') > 1) {
959
- // The renderer injected exactly one `name`, so a second is the author's.
960
- // Falling back to FORM_ACTION_FIELD matters for an EMPTY author name
961
- // (`name=${null}` emits `name=""`): the parse keeps the last duplicate, so
962
- // reading the value back would find `''` and the guard would wave through
963
- // a tag carrying two `name` attributes. A browser resolves that by keeping
964
- // the FIRST, so whichever came first would silently win, and SSR would
965
- // ship markup the client never produces.
966
- assertSubmitterHasNoName(attrs.get('name') || FORM_ACTION_FIELD, tag, false);
967
- }
968
- assertSubmitterType(tag, type);
969
- if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
970
- } else if (!isSubmitterType(tag, type)) {
971
- // `formmethod` / `formenctype` are inert on anything that does not submit,
972
- // so a `<input type="text" formenctype="text/plain">` is not a near-miss.
973
- return;
974
- }
975
-
976
- const formMethod = attrs.has('formmethod') ? attrs.get('formmethod') : null;
977
- const formEnctype = attrs.has('formenctype') ? attrs.get('formenctype') : null;
978
- if (formMethod == null && formEnctype == null) return;
979
- assertSubmitterSubmission(tag, formMethod, formEnctype, {
980
- bound,
981
- retargeted: !bound && attrs.has('formaction'),
982
- });
983
- }
984
-
985
- /**
986
- * Part B of #1207 on the CLIENT: sweep a bound form's submitters for a
987
- * `formmethod` / `formenctype` that would defeat the binding.
988
- *
989
- * SSR reaches the same rule a byte at a time as it emits each start tag inside
990
- * an open bound form. The client has no such stream, so it sweeps the form once
991
- * the binding is settled. Scoped to a bound form, which is rare on a page, so
992
- * the query cost is paid only where the rule applies.
993
- *
994
- * Runs AFTER every submitter in the pass has reconciled, which is what makes
995
- * `bound` and `retargeted` readable off the DOM: a bound submitter has had its
996
- * `formaction` removed and carries `name="__webjs_action"`, so a surviving
997
- * `formaction` attribute is the author's own static one.
998
- *
999
- * BEST EFFORT in the same sense as the enclosing-form check: a submitter living
1000
- * in a nested template that has not been inserted yet is simply not in the
1001
- * query, and SSR is the renderer that sees every page.
1002
- *
1003
- * ATTRIBUTE-BASED, which is a second limit worth stating. This reads the live
1004
- * DOM, so it cannot see a divergence that exists only in the TEMPLATE: a
1005
- * `.formMethod` / `.formEnctype` / `.formAction` PROPERTY on a submitter that
1006
- * binds no action of its own is refused by SSR (`assertConvergentSubmitter`
1007
- * runs there from the parsed start tag) and is invisible here, because the
1008
- * client builds a record only for a submitter that owns an action hole. Worse
1009
- * for `.formAction` specifically: a browser reflects it, so the query below
1010
- * reads a `formaction` attribute and treats the button as deliberately
1011
- * retargeted, skipping the check.
1012
- *
1013
- * Left as is on purpose. It is the server-strict direction, so the page never
1014
- * ships: SSR renders every page and refuses the shape before a browser sees it.
1015
- * The only way through is a component that renders ONLY on the client, and
1016
- * closing it would mean building template records for submitters that bind
1017
- * nothing, which is a wider change than the hole justifies. The reverse
1018
- * asymmetry, client refusing what SSR accepts, is the one this module treats as
1019
- * unacceptable, and none of these are that.
1020
- *
1021
- * @param {HTMLFormElement} form
1022
- * @returns {void}
1023
- */
1024
- export function assertBoundFormSubmitters(form) {
1025
- for (const el of form.querySelectorAll('button, input')) {
1026
- const tag = el.localName;
1027
- if (!isSubmitterType(tag, el.getAttribute('type'))) continue;
1028
- const formMethod = el.getAttribute('formmethod');
1029
- const formEnctype = el.getAttribute('formenctype');
1030
- if (formMethod == null && formEnctype == null) continue;
1031
- assertSubmitterSubmission(tag, formMethod, formEnctype, {
1032
- bound: el.getAttribute('name') === FORM_ACTION_FIELD,
1033
- retargeted: el.hasAttribute('formaction'),
1034
- });
995
+ assertConvergentSubmitter(shape.propAttrs, tag);
996
+ assertSingleSubmitterAction(!!shape.duplicateAction, tag);
997
+ if (attrs.has('formaction')) assertSubmitterHasNoStaticFormAction(tag);
998
+ if (countStartTagAttr(startTag, 'value') > 1) assertSubmitterHasNoValue(tag);
999
+ if (countStartTagAttr(startTag, 'name') > 1) {
1000
+ // The renderer injected exactly one `name`, so a second is the author's.
1001
+ // Falling back to FORM_ACTION_FIELD matters for an EMPTY author name
1002
+ // (`name=${null}` emits `name=""`): the parse keeps the last duplicate, so
1003
+ // reading the value back would find `''` and the guard would wave through
1004
+ // a tag carrying two `name` attributes. A browser resolves that by keeping
1005
+ // the FIRST, so whichever came first would silently win, and SSR would
1006
+ // ship markup the client never produces.
1007
+ assertSubmitterHasNoName(attrs.get('name') || FORM_ACTION_FIELD, tag, false);
1035
1008
  }
1009
+ assertSubmitterType(tag, type);
1010
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
1036
1011
  }
1037
1012
 
1038
1013
  /**
@@ -1090,6 +1065,32 @@ function releaseFormAction(form, method, enctype, propAttrs) {
1090
1065
  if (enctype === ABSENT && !owned('enctype')) form.removeAttribute('enctype');
1091
1066
  }
1092
1067
 
1068
+ /**
1069
+ * The submitter twin of `releaseFormAction`'s attribute half (#1307).
1070
+ *
1071
+ * When a submitter's action hole stops resolving to an action, the `formmethod`
1072
+ * / `formenctype` the framework supplied must come off with the identity, or a
1073
+ * released button keeps attributes SSR does not emit for the same template.
1074
+ *
1075
+ * Recomputed rather than remembered, exactly as the form version is: an
1076
+ * attribute is the framework's precisely when the template supplies nothing for
1077
+ * it on THIS pass, so there is no bookkeeping to go stale. A `.formMethod` /
1078
+ * `.formEnctype` PROPERTY binding is named rather than inferred for the same
1079
+ * reason it is on a form: its write reflects to the content attribute but it is
1080
+ * not an attribute part, so "the template supplies nothing" reads true and a
1081
+ * blind removal would wipe the author's own value on every re-render.
1082
+ *
1083
+ * @param {Element} el
1084
+ * @param {string | typeof ABSENT} formMethod
1085
+ * @param {string | typeof ABSENT} formEnctype
1086
+ * @param {string[] | undefined} propAttrs attribute names a property part owns
1087
+ */
1088
+ export function releaseSubmitterAttrs(el, formMethod, formEnctype, propAttrs) {
1089
+ const owned = (name) => (propAttrs || []).some((p) => String(p).toLowerCase() === name);
1090
+ if (formMethod === ABSENT && !owned('formmethod')) el.removeAttribute('formmethod');
1091
+ if (formEnctype === ABSENT && !owned('formenctype')) el.removeAttribute('formenctype');
1092
+ }
1093
+
1093
1094
  /**
1094
1095
  * Parse the attributes of an emitted start tag into `name -> value`, where a
1095
1096
  * valueless attribute maps to the empty string.