@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.
- package/README.md +3 -2
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -21
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +13 -5
- package/index.d.ts +2 -2
- package/index.js +2 -2
- package/package.json +1 -1
- package/src/action-seed-client.d.ts +23 -1
- package/src/action-seed-client.js +359 -17
- package/src/component.d.ts +7 -3
- package/src/component.js +94 -2
- package/src/form-action.js +204 -203
- package/src/render-client.js +94 -96
- package/src/render-server.js +100 -118
- package/src/router-client.js +644 -21
- package/src/websocket-client.js +5 -2
package/src/component.js
CHANGED
|
@@ -157,6 +157,42 @@ function warnFunctionReflection(host, propName, attrName) {
|
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Warn that a value with no JSON representation was dropped rather than
|
|
162
|
+
* reflected (#1253).
|
|
163
|
+
*
|
|
164
|
+
* `JSON.stringify` throws for three reasons an app hits in practice: a cycle,
|
|
165
|
+
* a `BigInt`, and an author `toJSON()` that throws. All three mean the same
|
|
166
|
+
* thing here, that there is no string to put in the attribute, so all three
|
|
167
|
+
* get the same answer as a function does.
|
|
168
|
+
*
|
|
169
|
+
* The caught message IS included, unlike `warnFunctionReflection`, which
|
|
170
|
+
* withholds its value on purpose. A function's string form is its source,
|
|
171
|
+
* which is the leak that guard exists to prevent; `JSON.stringify`'s own
|
|
172
|
+
* message names the property path rather than a value, and the sibling
|
|
173
|
+
* `.prop=${val}` SSR drop in `render-server.js` already ends the same way.
|
|
174
|
+
*
|
|
175
|
+
* UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a
|
|
176
|
+
* `NODE_ENV` gate is folded to a constant by the dist build, which would make
|
|
177
|
+
* the SSR half unreachable in every published build, and the SSR half is where
|
|
178
|
+
* the component silently vanishes.
|
|
179
|
+
*
|
|
180
|
+
* @param {{ constructor: unknown, tagName?: string }} host
|
|
181
|
+
* @param {string} propName
|
|
182
|
+
* @param {string} attrName
|
|
183
|
+
* @param {string} [detail] the message `JSON.stringify` threw
|
|
184
|
+
*/
|
|
185
|
+
function warnUnserializableReflection(host, propName, attrName, detail) {
|
|
186
|
+
if (typeof console === 'undefined' || !console.warn) return;
|
|
187
|
+
const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
|
|
188
|
+
console.warn(
|
|
189
|
+
`[webjs] reflect:true property "${propName}" on <${tag}> holds a value `
|
|
190
|
+
+ `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing `
|
|
191
|
+
+ `toJSON), so it has no HTML attribute representation. Removing `
|
|
192
|
+
+ `"${attrName}" instead. Detail: ${detail}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
/**
|
|
161
197
|
* A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
|
|
162
198
|
* while staying JSDoc-only and no-build.
|
|
@@ -745,12 +781,40 @@ class WebComponentBase extends Base {
|
|
|
745
781
|
} else if (value == null) {
|
|
746
782
|
this.removeAttribute(attrName);
|
|
747
783
|
} else if (decl.type === Object || decl.type === Array) {
|
|
784
|
+
// One rule about an unserializable reflected value, in two halves.
|
|
785
|
+
//
|
|
748
786
|
// A JSON-typed prop CARRYING a function is safe and stays whole:
|
|
749
787
|
// `JSON.stringify` drops a function to `null` in an array and omits
|
|
750
788
|
// the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
|
|
751
789
|
// with no source and no data loss. Refusing here would discard the
|
|
752
790
|
// `1` and the `2` on a path that never leaked.
|
|
753
|
-
|
|
791
|
+
//
|
|
792
|
+
// A value `JSON.stringify` cannot serialize AT ALL is the other half
|
|
793
|
+
// (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
|
|
794
|
+
// The throw used to escape reflection entirely, so a property
|
|
795
|
+
// assignment threw from the setter, a client upgrade threw out of
|
|
796
|
+
// `connectedCallback` before the first render, and an SSR render was
|
|
797
|
+
// swallowed by per-component isolation, which surfaces an error box in
|
|
798
|
+
// dev and emits the component EMPTY at a 200 in PRODUCTION, where the
|
|
799
|
+
// cause is only in the server log. There is no string to put in the
|
|
800
|
+
// attribute, so the
|
|
801
|
+
// attribute goes, exactly as it does for a function. The `catch` IS
|
|
802
|
+
// the detection: `JSON.stringify` already walks the value, so its own
|
|
803
|
+
// failure covers all three causes, where a cycle-only pre-walk would
|
|
804
|
+
// miss the other two and pay a full traversal per reflection.
|
|
805
|
+
let serialized;
|
|
806
|
+
let serializable = true;
|
|
807
|
+
try {
|
|
808
|
+
serialized = JSON.stringify(value);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
serializable = false;
|
|
811
|
+
this.removeAttribute(attrName);
|
|
812
|
+
warnUnserializableReflection(this, propName, attrName, e && e.message);
|
|
813
|
+
}
|
|
814
|
+
// Outside the `try` on purpose, so a genuine `setAttribute` failure
|
|
815
|
+
// (an invalid attribute name) still surfaces instead of being folded
|
|
816
|
+
// into the unserializable path.
|
|
817
|
+
if (serializable) this.setAttribute(attrName, serialized);
|
|
754
818
|
} else if (carriesFunction(value)) {
|
|
755
819
|
// The string fall-through is the one place a CARRIED function still
|
|
756
820
|
// leaks. `String([fn])` is `Array.prototype.join`, which runs
|
|
@@ -1103,7 +1167,35 @@ class WebComponentBase extends Base {
|
|
|
1103
1167
|
} else if (def.type === Boolean) {
|
|
1104
1168
|
v = value != null && value !== 'false';
|
|
1105
1169
|
} else if (def.type === Object || def.type === Array) {
|
|
1106
|
-
|
|
1170
|
+
// An attribute that is not parseable JSON yields `null` rather than the
|
|
1171
|
+
// raw string (#1253), because a STRING is never a valid value for a
|
|
1172
|
+
// property the author declared `Object` or `Array`, whatever put it
|
|
1173
|
+
// there. lit's `defaultConverter.fromAttribute` lands on the same `null`,
|
|
1174
|
+
// for the reason its own comment gives: an element does not complain
|
|
1175
|
+
// about being mis-configured.
|
|
1176
|
+
//
|
|
1177
|
+
// `applyAttrsToInstance` in `render-server.js` is the SSR counterpart of
|
|
1178
|
+
// THIS branch and yields `null` for the same input. They have to agree,
|
|
1179
|
+
// or an element SSRs holding one value and re-renders holding another.
|
|
1180
|
+
// The agreement is about an attribute that is PRESENT and unparseable;
|
|
1181
|
+
// an attribute that was never there does not reach either reader, so
|
|
1182
|
+
// such a prop simply keeps its constructor value.
|
|
1183
|
+
//
|
|
1184
|
+
// That agreement is about this FALLBACK, not a guarantee that the two
|
|
1185
|
+
// readers see the same attributes in the first place. They reach an
|
|
1186
|
+
// attribute by different routes (this one via `observedAttributes` and
|
|
1187
|
+
// the browser's own name-lowercasing, the SSR one by walking the parsed
|
|
1188
|
+
// source tag), and hand-written markup can land in the gaps between
|
|
1189
|
+
// those routes, so an author literal can still be read by one and not
|
|
1190
|
+
// the other. Those gaps predate #1253 and it neither causes nor closes
|
|
1191
|
+
// any of them; no attempt is made here to enumerate them, because every
|
|
1192
|
+
// attempt so far has been incomplete.
|
|
1193
|
+
//
|
|
1194
|
+
// Scoped to the default converter. A prop supplying its own
|
|
1195
|
+
// `converter.fromAttribute` is handled by the arm above and the SSR
|
|
1196
|
+
// reader has no converter arm at all, so the two sides already read such
|
|
1197
|
+
// a prop differently. That gap predates #1253 and is left alone.
|
|
1198
|
+
try { v = value == null ? null : JSON.parse(value); } catch { v = null; }
|
|
1107
1199
|
} else {
|
|
1108
1200
|
v = value;
|
|
1109
1201
|
}
|
package/src/form-action.js
CHANGED
|
@@ -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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
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
|
|
411
|
-
|
|
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
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
* and
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
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
|
|
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 (
|
|
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}"
|
|
498
|
-
+ `
|
|
499
|
-
+ `
|
|
500
|
-
+ `
|
|
501
|
-
+ `
|
|
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}"
|
|
507
|
-
+ `
|
|
508
|
-
+ `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so `
|
|
509
|
-
+ `
|
|
510
|
-
+ `
|
|
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
|
-
*
|
|
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(
|
|
872
|
-
if (inject !== null) {
|
|
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 &&
|
|
877
|
-
|
|
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
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
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 {{
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
if (
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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.
|