@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.
- 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 +5 -1
- package/index.d.ts +4 -1
- package/index.js +5 -1
- package/package.json +2 -2
- 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 +69 -0
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1128 -33
- package/src/optimistic.js +7 -2
- package/src/render-client.js +978 -111
- package/src/render-server.js +314 -24
- package/src/router-client.js +941 -33
- package/src/webjs-config.d.ts +49 -17
package/src/form-action.js
CHANGED
|
@@ -1,44 +1,489 @@
|
|
|
1
1
|
import { isBindingPrefix } from './binding-prefixes.js';
|
|
2
|
+
import { escapeAttr } from './escape.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Form
|
|
5
|
+
* Form actions: binding a `'use server'` action straight into a form (#1155),
|
|
6
|
+
* and the guard that refuses every OTHER way a function could reach the markup
|
|
7
|
+
* (#1154).
|
|
8
|
+
*
|
|
9
|
+
* ONE supported shape, and it is the shape a Next-trained author already
|
|
10
|
+
* writes:
|
|
11
|
+
*
|
|
12
|
+
* import { submitFeedback } from '#modules/feedback/actions/submit.server.ts';
|
|
13
|
+
* html`<form action=${submitFeedback}> ... </form>`
|
|
14
|
+
*
|
|
15
|
+
* The renderers do not stringify that function. They resolve its IDENTITY
|
|
16
|
+
* (`<file-hash>/<export-name>`, the same naming the RPC endpoint uses), drop
|
|
17
|
+
* the `action` attribute so the form posts to the page's own url, force the
|
|
18
|
+
* attributes the submission needs (`method="post"`, `enctype`), and emit one
|
|
19
|
+
* hidden field carrying the identity. The server resolves that field back to
|
|
20
|
+
* the function and runs it. Nothing about the action's source reaches the
|
|
21
|
+
* browser, and the form works with JS off because it is an ordinary HTML
|
|
22
|
+
* submission.
|
|
23
|
+
*
|
|
24
|
+
* Every other function-in-an-action shape stays REFUSED, because it would
|
|
25
|
+
* stringify the function's body into the served HTML. At SSR a `'use server'`
|
|
26
|
+
* import is the REAL server function (the RPC stub only exists in the
|
|
27
|
+
* browser), so a plain `String(val)` commit serializes the action's body,
|
|
28
|
+
* secrets included, to every visitor. The refusal list is deliberately wider
|
|
29
|
+
* than the leak: a quoted `action="${fn}"`, `?action=${fn}`, an array holding
|
|
30
|
+
* a function, an unsupported `formaction=` shape, and `action=` on a tag that
|
|
31
|
+
* is not a `<form>` all refuse, so there is exactly one way to write each
|
|
32
|
+
* binding and no silently-broken near-miss.
|
|
33
|
+
*
|
|
34
|
+
* `formaction=${fn}` on a SUBMITTER is the second supported shape (#1207), for
|
|
35
|
+
* a form whose buttons run different actions. The identity rides the channel a
|
|
36
|
+
* browser gives only to the button that was pressed: its own `name`/`value`
|
|
37
|
+
* pair. So the renderers emit `<button name="__webjs_action" value="<id>">` and
|
|
38
|
+
* no `formaction` url at all, exactly as a bound `action=${fn}` emits no
|
|
39
|
+
* `action`. The form-level hidden field is the form's FIRST child and SSR emits
|
|
40
|
+
* it right after the start tag, so a submitter's entry necessarily follows it
|
|
41
|
+
* in DOM order; the dispatcher reads `getAll(FORM_ACTION_FIELD)` and takes the
|
|
42
|
+
* LAST, which is the submitter's whenever one was pressed. Nothing new is
|
|
43
|
+
* needed on the JS path either: `new FormData(form, submitter)` already
|
|
44
|
+
* includes the pressed button's pair.
|
|
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
|
+
*
|
|
60
|
+
* Refused shapes on a submitter, each because the identity would not arrive:
|
|
61
|
+
* - Its own `name` (static `name="..."` or a `name=${n}` hole) or its own
|
|
62
|
+
* `value`, because the identity occupies both halves of that one pair.
|
|
63
|
+
* - `<input type="image">`, which submits `name.x` / `name.y` coordinates
|
|
64
|
+
* instead of `name=value`, so the identity would never arrive.
|
|
65
|
+
* - A control that is not a submitter at all, where `formaction` is inert.
|
|
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.
|
|
5
83
|
*
|
|
6
84
|
* Both SSR state machines and the client renderer import from here so the
|
|
7
|
-
*
|
|
8
|
-
* property holes on separate branches, so
|
|
9
|
-
* looks like from any one file
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Two entry points, and the difference between them is the point:
|
|
13
|
-
* `assertNotFunctionActionAttr` is name-based, because an ATTRIBUTE is
|
|
14
|
-
* stringified into the markup on whatever tag it appears.
|
|
15
|
-
* `assertNotFunctionReflectedActionProp` is name-and-tag-based, because a
|
|
16
|
-
* PROPERTY only reaches the markup where the DOM reflects it. Using the first
|
|
17
|
-
* on the property path refused `<div .action=${fn}>`, which never leaked.
|
|
85
|
+
* rules cannot drift between them. Each renderer commits attribute, boolean
|
|
86
|
+
* and property holes on separate branches, so they have more call sites than
|
|
87
|
+
* it looks like from any one file and are easy to change one at a time. That
|
|
88
|
+
* is why the predicates live here rather than at each site.
|
|
18
89
|
*
|
|
19
90
|
* The second SSR machine (`streamTemplate`) is reached only through
|
|
20
91
|
* `renderToStream(v, { ssr: false })`, which no page render uses: the server
|
|
21
|
-
* renders every page, Suspense included, via `renderToString`.
|
|
22
|
-
*
|
|
23
|
-
* from a bug there that pages were affected.
|
|
92
|
+
* renders every page, Suspense included, via `renderToString`. It is kept in
|
|
93
|
+
* lockstep for the public API surface rather than for a live page.
|
|
24
94
|
*/
|
|
25
95
|
|
|
26
96
|
/**
|
|
27
|
-
*
|
|
97
|
+
* The hidden field carrying a bound action's identity. The server's form
|
|
98
|
+
* dispatcher reads it off the submitted `FormData`; nothing else is
|
|
99
|
+
* authoritative, so a form that lost this field is never dispatched by
|
|
100
|
+
* guesswork.
|
|
101
|
+
*/
|
|
102
|
+
export const FORM_ACTION_FIELD = '__webjs_action';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The property a generated client RPC stub carries its own identity on.
|
|
106
|
+
* Written by the generated stub source (`packages/server/src/actions.js`), read
|
|
107
|
+
* here. A plain string property rather than a symbol because the stub is
|
|
108
|
+
* generated source that only imports the framework's runtime helpers.
|
|
109
|
+
*/
|
|
110
|
+
export const FORM_ACTION_ID_KEY = '$$webjsAction';
|
|
111
|
+
|
|
112
|
+
/** @type {((fn: Function) => (string | null | Promise<string | null>)) | null} */
|
|
113
|
+
let _resolver = null;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Internal: server-only wiring. `@webjsdev/server` installs the resolver that
|
|
117
|
+
* maps a REAL server-action function back to its `<hash>/<fn>` identity, which
|
|
118
|
+
* it knows from the module load hook that registered the function. The browser
|
|
119
|
+
* never calls this: a stub carries its identity on itself, so
|
|
120
|
+
* `formActionId` resolves synchronously there.
|
|
121
|
+
*
|
|
122
|
+
* @param {(fn: Function) => (string | null | Promise<string | null>)} fn
|
|
123
|
+
* @returns {void}
|
|
124
|
+
*/
|
|
125
|
+
export function setFormActionResolver(fn) {
|
|
126
|
+
_resolver = fn;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The identity of an action function, or null when it has none.
|
|
131
|
+
*
|
|
132
|
+
* Synchronous, and that is a constraint rather than a preference: the client
|
|
133
|
+
* renderer commits attributes synchronously, so identity has to be readable
|
|
134
|
+
* without awaiting there. A stub carries its own, so the client path is a
|
|
135
|
+
* property read. On the server the resolver is consulted, and it may answer
|
|
136
|
+
* with a promise, which `resolveFormActionId` awaits and this does not.
|
|
137
|
+
*
|
|
138
|
+
* @param {unknown} fn
|
|
139
|
+
* @returns {string | null}
|
|
140
|
+
*/
|
|
141
|
+
export function formActionId(fn) {
|
|
142
|
+
if (typeof fn !== 'function') return null;
|
|
143
|
+
const own = /** @type any */ (fn)[FORM_ACTION_ID_KEY];
|
|
144
|
+
return typeof own === 'string' && own ? own : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The identity of an action function, consulting the server resolver when the
|
|
149
|
+
* function does not carry one. Used by the SSR renderers, which are async.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} fn
|
|
152
|
+
* @returns {Promise<string | null>}
|
|
153
|
+
*/
|
|
154
|
+
export async function resolveFormActionId(fn) {
|
|
155
|
+
const own = formActionId(fn);
|
|
156
|
+
if (own) return own;
|
|
157
|
+
if (typeof fn !== 'function' || !_resolver) return null;
|
|
158
|
+
try {
|
|
159
|
+
const id = await _resolver(/** @type {Function} */ (fn));
|
|
160
|
+
return typeof id === 'string' && id ? id : null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Is this hole one of the two supported form-action bindings: an unquoted
|
|
168
|
+
* `action=${fn}` on a `<form>`, or an unquoted `formaction=${fn}` on a
|
|
169
|
+
* `<button>` / `<input>`?
|
|
170
|
+
*
|
|
171
|
+
* Unquoted matters in both cases, because quoting turns a binding hole back
|
|
172
|
+
* into a plain attribute the renderer stringifies, and there is no way to bind
|
|
173
|
+
* half an action into a url. The TAG matters because those are the only
|
|
174
|
+
* elements whose `action` / `formaction` submits anything; anywhere else the
|
|
175
|
+
* attribute is inert and binding a function to it means the author expected
|
|
176
|
+
* something that will never happen.
|
|
177
|
+
*
|
|
178
|
+
* Whether the `<button>` is really a SUBMITTER, and whether its form is bound,
|
|
179
|
+
* are checked separately: they depend on attributes that may be written after
|
|
180
|
+
* this hole, so they are judged once the whole start tag is known.
|
|
181
|
+
*
|
|
182
|
+
* @param {unknown} val the hole's resolved value
|
|
183
|
+
* @param {string} attrName the AUTHORED attribute name (no sigil, unquoted branch)
|
|
184
|
+
* @param {string} [tag] lowercased owner tag
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function isBoundFormAction(val, attrName, tag) {
|
|
188
|
+
if (typeof val !== 'function') return false;
|
|
189
|
+
const attr = String(attrName).toLowerCase();
|
|
190
|
+
const t = String(tag || '').toLowerCase();
|
|
191
|
+
if (attr === 'action') return t === 'form';
|
|
192
|
+
if (attr === 'formaction') return t === 'button' || t === 'input';
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Refuse a submitter carrying its own `name` alongside `formaction=${fn}`.
|
|
198
|
+
*
|
|
199
|
+
* The identity needs BOTH halves of the submitter's one `name`/`value` pair, so
|
|
200
|
+
* an author `name` has nowhere to go. `allowFrameworkName` is what lets this
|
|
201
|
+
* run twice over the same element without tripping on the framework's own
|
|
202
|
+
* write: the renderers emit `name="__webjs_action"` themselves, and the client
|
|
203
|
+
* re-reconciles an element it already stamped on a previous pass, so a `name`
|
|
204
|
+
* that IS the identity field is ours and not the author's. It is passed false
|
|
205
|
+
* only where the caller has already established the author wrote one too (SSR
|
|
206
|
+
* sees two `name` attributes on the emitted tag; the client recorded a `name`
|
|
207
|
+
* part or a static `name` on the compiled template).
|
|
208
|
+
*
|
|
209
|
+
* @param {string} name
|
|
210
|
+
* @param {string} [tag]
|
|
211
|
+
* @param {boolean} [allowFrameworkName] treat `name="__webjs_action"` as the framework's own
|
|
212
|
+
*/
|
|
213
|
+
export function assertSubmitterHasNoName(name, tag, allowFrameworkName = true) {
|
|
214
|
+
if (!name) return;
|
|
215
|
+
if (name === FORM_ACTION_FIELD && allowFrameworkName) return;
|
|
216
|
+
const found = name === FORM_ACTION_FIELD
|
|
217
|
+
? 'a second "name" attribute'
|
|
218
|
+
: `name="${name}"`;
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used when `
|
|
221
|
+
+ `the element already carries a "name" attribute (found ${found}). The `
|
|
222
|
+
+ `action identity is submitted as the button's own name/value pair, so `
|
|
223
|
+
+ `there is no room for a second one. Move the action to the enclosing `
|
|
224
|
+
+ `<form action=\${action}> or drop the submitter's "name".`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Refuse a formaction binding on a control that is not a submitter.
|
|
230
|
+
*
|
|
231
|
+
* `<input type="image">` gets its own message rather than the generic one: it
|
|
232
|
+
* IS a submitter, so "use a submitter" would be unhelpful advice. It submits
|
|
233
|
+
* `name.x` / `name.y` coordinate pairs instead of `name=value`, so the identity
|
|
234
|
+
* would never arrive and the form would look bound while posting nothing.
|
|
235
|
+
*
|
|
236
|
+
* A `<button>` with no `type` is a submitter (submit is the HTML default),
|
|
237
|
+
* which is why an absent type passes here.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} tag
|
|
240
|
+
* @param {string | null | undefined} type
|
|
241
|
+
*/
|
|
242
|
+
export function assertSubmitterType(tag, type) {
|
|
243
|
+
const t = String(tag || '').toLowerCase();
|
|
244
|
+
const value = type == null ? null : String(type).toLowerCase();
|
|
245
|
+
if (isSubmitterType(t, value)) {
|
|
246
|
+
if (t === 'input' && value === 'image') {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[webjs] formaction=\${action} is not supported on <input type="image"> `
|
|
249
|
+
+ `because image submitters submit coordinate pairs (name.x/name.y) `
|
|
250
|
+
+ `instead of name=value, so the action identity would never arrive. `
|
|
251
|
+
+ `Use a <button> instead.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (t === 'input') {
|
|
255
|
+
// `<input type="submit">` IS a submitter and Part B still judges its
|
|
256
|
+
// `formmethod` / `formenctype`, but it cannot carry a BINDING. The
|
|
257
|
+
// identity has to occupy the submitter's `value`, and on this control
|
|
258
|
+
// `value` is also the visible caption, so binding an action renders a
|
|
259
|
+
// button captioned `a1b2c3d4e5/publishDraft`. The only fix, an author
|
|
260
|
+
// `value="Publish"`, is the very channel the identity needs, so the two
|
|
261
|
+
// requirements cannot both be met. A `<button>` has no such conflict: its
|
|
262
|
+
// label is its children.
|
|
263
|
+
throw new Error(
|
|
264
|
+
`[webjs] formaction=\${action} is not supported on <input type="submit"> `
|
|
265
|
+
+ `because the action identity has to occupy the submitter's "value", `
|
|
266
|
+
+ `which on this control is also its visible label, so the button would `
|
|
267
|
+
+ `render captioned with the action's id and could not be given a real `
|
|
268
|
+
+ `one. Use <button formaction=\${action}>Publish</button>, whose label `
|
|
269
|
+
+ `is its children.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error(
|
|
275
|
+
`[webjs] formaction=\${action} on <${t}>${value ? ` type="${value}"` : ''} `
|
|
276
|
+
+ `requires a submitter control, and formaction is inert on anything else. `
|
|
277
|
+
+ `Use <button formaction=\${action}>.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Is this tag/type pair a control that SUBMITS its form?
|
|
283
|
+
*
|
|
284
|
+
* Shared by the binding guard and the Part B sweep, which need the same answer
|
|
285
|
+
* for opposite reasons: the first refuses `formaction=${fn}` on a non-submitter,
|
|
286
|
+
* the second only inspects `formmethod` / `formenctype` on a real submitter,
|
|
287
|
+
* where those attributes are not inert.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} tag lowercased
|
|
290
|
+
* @param {string | null | undefined} type lowercased, or null when absent
|
|
291
|
+
* @returns {boolean}
|
|
292
|
+
*/
|
|
293
|
+
export function isSubmitterType(tag, type) {
|
|
294
|
+
const value = type == null ? null : String(type).toLowerCase();
|
|
295
|
+
if (tag === 'button') return value == null || value === '' || value === 'submit';
|
|
296
|
+
if (tag === 'input') return value === 'submit' || value === 'image';
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Refuse an author `value` on a bound submitter, for the same reason as `name`:
|
|
302
|
+
* the identity is a name/value PAIR, so both halves are spoken for.
|
|
303
|
+
* @param {string} tag
|
|
304
|
+
*/
|
|
305
|
+
export function assertSubmitterHasNoValue(tag) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used when `
|
|
308
|
+
+ `the element already carries a "value" attribute. The action identity is `
|
|
309
|
+
+ `submitted as the button's own name/value pair, so both halves are taken. `
|
|
310
|
+
+ `Drop the "value", or dispatch on it from one form-level action instead.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Refuse a static `formaction="/url"` alongside the bound hole.
|
|
316
|
+
*
|
|
317
|
+
* The same failure as a static `action="/url"` on a bound form, one level down:
|
|
318
|
+
* SSR only rewrites the attribute the HOLE wrote, so the static one survives
|
|
319
|
+
* into the emitted tag, while the client's reconcile removes it. With JS off
|
|
320
|
+
* the browser posts the identity to `/url`; with JS on the router posts to the
|
|
321
|
+
* page. Same template, two different targets.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} tag
|
|
324
|
+
*/
|
|
325
|
+
export function assertSubmitterHasNoStaticFormAction(tag) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`[webjs] a bound formaction=\${action} on <${tag || 'button'}> cannot also `
|
|
328
|
+
+ `carry a plain formaction attribute: the two name different targets and `
|
|
329
|
+
+ `the renderers resolve them differently. Drop the static attribute.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Refuse a submitter whose form owner is chosen through the `form` attribute.
|
|
28
335
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
336
|
+
* Boundness was just established for the ENCLOSING form. A `form="other"`
|
|
337
|
+
* re-points the submitter at a different one, which may not be bound at all, so
|
|
338
|
+
* the check that passed would have been about the wrong element.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} tag
|
|
341
|
+
*/
|
|
342
|
+
export function assertSubmitterHasNoFormAttribute(tag) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used with `
|
|
345
|
+
+ `a "form" attribute, which re-points the submitter at a form other than `
|
|
346
|
+
+ `the bound one it sits in. Keep the submitter inside its bound <form>.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Refuse two `formaction=${...}` holes on one submitter, the per-button twin of
|
|
352
|
+
* the duplicate-`action` refusal: SSR would emit the second alongside the
|
|
353
|
+
* identity it already wrote, while the client resolves last-wins.
|
|
354
|
+
*
|
|
355
|
+
* @param {boolean} duplicate
|
|
356
|
+
* @param {string} tag
|
|
357
|
+
*/
|
|
358
|
+
export function assertSingleSubmitterAction(duplicate, tag) {
|
|
359
|
+
if (!duplicate) return;
|
|
360
|
+
throw new Error(
|
|
361
|
+
`[webjs] two formaction=\${action} holes were found on one <${tag || 'button'}>. `
|
|
362
|
+
+ `Only one can win, and the two renderers pick differently, so bind exactly `
|
|
363
|
+
+ `one action per submitter.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The submitter twin of `assertConvergentBoundForm`: refuse a `.prop` spelling
|
|
369
|
+
* on a submitter that the two renderers can never agree on.
|
|
370
|
+
*
|
|
371
|
+
* Same argument, one level down. A `.prop` on a native element is DROPPED at
|
|
372
|
+
* SSR and applied for real in the browser, and on a `<button>` / `<input>` all
|
|
373
|
+
* of `name`, `value`, `formAction`, `formMethod` and `formEnctype` are
|
|
374
|
+
* REFLECTED IDL attributes, so the browser write lands in the content
|
|
375
|
+
* attribute. Measured: `<button .name=${'intent'} formaction=${fn}>` renders
|
|
376
|
+
* clean at SSR (the prop is dropped, so the identity's `name` survives) and
|
|
377
|
+
* then throws on hydration, where `.name` has written `name="intent"` over it.
|
|
378
|
+
* That is the render-on-the-server, crash-on-hydration direction, which is the
|
|
379
|
+
* one failure this module treats as unacceptable.
|
|
380
|
+
*
|
|
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`.
|
|
388
|
+
*
|
|
389
|
+
* @param {string[] | undefined} propAttrs attribute names a property part owns
|
|
390
|
+
* @param {string} tag lowercased owner tag
|
|
391
|
+
* @returns {void}
|
|
392
|
+
*/
|
|
393
|
+
export function assertConvergentSubmitter(propAttrs, tag) {
|
|
394
|
+
const relevant = (propAttrs || []).filter((n) => isSubmitterReflectedProp(n));
|
|
395
|
+
const prop = relevant[0];
|
|
396
|
+
if (!prop) return;
|
|
397
|
+
throw new Error(
|
|
398
|
+
`[webjs] a <${tag || 'button'}> submitter binds .${prop}=. That property is a `
|
|
399
|
+
+ `reflected IDL attribute on a submitter, so a property binding is dropped `
|
|
400
|
+
+ `at SSR and written to the attribute in the browser: the page would render `
|
|
401
|
+
+ `on the server and throw on hydration. Write it as a plain attribute.`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Is this property name one whose write reflects into a submitter attribute the
|
|
407
|
+
* form-action rules care about?
|
|
408
|
+
*
|
|
409
|
+
* @param {string} name any case, sigil already stripped
|
|
410
|
+
* @returns {boolean}
|
|
411
|
+
*/
|
|
412
|
+
export function isSubmitterReflectedProp(name) {
|
|
413
|
+
const n = String(name).toLowerCase();
|
|
414
|
+
return n === 'name' || n === 'value' || n === 'formaction'
|
|
415
|
+
|| n === 'formmethod' || n === 'formenctype';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
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.
|
|
435
|
+
*
|
|
436
|
+
* @param {string} tag lowercased owner tag, for the message
|
|
437
|
+
* @param {string | null | undefined} formMethod the submitter's `formmethod`, or null
|
|
438
|
+
* @param {string | null | undefined} formEnctype the submitter's `formenctype`, or null
|
|
439
|
+
*/
|
|
440
|
+
export function assertSubmittableSubmitter(tag, formMethod, formEnctype) {
|
|
441
|
+
const method = formMethod == null ? null : String(formMethod);
|
|
442
|
+
const enctype = formEnctype == null ? null : String(formEnctype);
|
|
443
|
+
|
|
444
|
+
if (method != null && /^dialog$/i.test(method)) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`[webjs] formaction=\${action} on <${tag}> cannot be combined with `
|
|
447
|
+
+ `formmethod="dialog", which dismisses a <dialog> instead of submitting, `
|
|
448
|
+
+ `so the bound action would never run. Drop one of the two.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Compared UNTRIMMED, exactly as `assertSubmittableForm` compares a form's
|
|
453
|
+
// own `method`. These are enumerated attributes a browser matches against
|
|
454
|
+
// exact keywords with no whitespace stripping, so `formmethod=" post "` falls
|
|
455
|
+
// to the invalid-value default and the button submits as a GET. Trimming here
|
|
456
|
+
// would accept the padded value, emit it untouched, and produce exactly the
|
|
457
|
+
// silently-posts-nowhere submitter this refusal exists to make impossible.
|
|
458
|
+
if (method != null && !/^post$/i.test(method)) {
|
|
459
|
+
throw new Error(
|
|
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.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) {
|
|
468
|
+
throw new Error(
|
|
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).`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Refuse to stringify a function interpolated into a form-action attribute.
|
|
37
480
|
*
|
|
38
481
|
* Name-based on purpose (`action`, plus `formaction`, the submit-button
|
|
39
|
-
* override, which leaks identically): a function stringified under these
|
|
40
|
-
*
|
|
41
|
-
*
|
|
482
|
+
* override, which leaks identically): a function stringified under these names
|
|
483
|
+
* is never useful on any tag, and other attributes keep today's stringify
|
|
484
|
+
* behaviour so the claim stays narrow. The one supported shape
|
|
485
|
+
* (`isBoundFormAction`) is claimed by the caller BEFORE this runs, so
|
|
486
|
+
* everything reaching here is a shape with no meaning.
|
|
42
487
|
*
|
|
43
488
|
* @param {unknown} val the hole's resolved value
|
|
44
489
|
* @param {string} attrName the attribute being committed (any case)
|
|
@@ -69,9 +514,24 @@ export function assertNotFunctionActionAttr(val, attrName, tag) {
|
|
|
69
514
|
*
|
|
70
515
|
* A custom element is excluded for the same reason rather than a different
|
|
71
516
|
* one: it has no IDL reflection, so its `.action` is an author-defined
|
|
72
|
-
* property and a function is a legitimate value.
|
|
73
|
-
*
|
|
74
|
-
* the setter and never reaches any commit site.
|
|
517
|
+
* property and a function is a legitimate value. There is a separate path
|
|
518
|
+
* that reflects on a custom element, a prop declared `reflect: true`, which
|
|
519
|
+
* runs in the setter and never reaches any commit site. It used to write the
|
|
520
|
+
* source, which is why this carve-out was once narrower than it looked.
|
|
521
|
+
* #1169 closed it at the setter, where a function now removes the attribute.
|
|
522
|
+
*
|
|
523
|
+
* That close is not total, so do not read this exclusion as a guarantee the
|
|
524
|
+
* source can never reach a custom element's attribute. `_reflectAttribute`
|
|
525
|
+
* runs a prop's own `converter.toAttribute` BEFORE the function guard, so an
|
|
526
|
+
* author who supplies one still owns what gets written, a stringified
|
|
527
|
+
* function included. That is deliberate (supplying a converter is taking
|
|
528
|
+
* responsibility for the serialization), but it means the guarantee here
|
|
529
|
+
* covers the converter-less case.
|
|
530
|
+
*
|
|
531
|
+
* `<form .action=${fn}>` is refused rather than treated as the supported
|
|
532
|
+
* binding: the supported one is the plain `action=${fn}` attribute, and a
|
|
533
|
+
* `.prop` binding on a native element is dropped at SSR, so accepting it would
|
|
534
|
+
* mean a form that submits under JS and does nothing without it.
|
|
75
535
|
*
|
|
76
536
|
* @param {unknown} val the hole's resolved value
|
|
77
537
|
* @param {string} propName the property being assigned (any case)
|
|
@@ -82,6 +542,637 @@ export function assertNotFunctionReflectedActionProp(val, propName, tag) {
|
|
|
82
542
|
assertNotFunctionActionAttr(val, propName, tag);
|
|
83
543
|
}
|
|
84
544
|
|
|
545
|
+
/**
|
|
546
|
+
* Refuse a bound action the renderer cannot identify.
|
|
547
|
+
*
|
|
548
|
+
* A function reaches here having passed `isBoundFormAction`, so the author
|
|
549
|
+
* meant it as a server action. If identity resolution came back empty it is
|
|
550
|
+
* not one: a local closure, a plain helper, or a `'use server'` module the
|
|
551
|
+
* server never registered. Emitting the form anyway would produce markup that
|
|
552
|
+
* looks right and silently posts nowhere, which is the one outcome a
|
|
553
|
+
* progressive-enhancement form must never have, so this throws instead.
|
|
554
|
+
*
|
|
555
|
+
* @param {string | null} id
|
|
556
|
+
* @param {string} [tag]
|
|
557
|
+
* @returns {string} the identity, narrowed
|
|
558
|
+
*/
|
|
559
|
+
export function assertIdentifiableAction(id, tag) {
|
|
560
|
+
if (id) return id;
|
|
561
|
+
const attr = tag === 'button' || tag === 'input' ? 'formaction=' : 'action=';
|
|
562
|
+
throw new Error(
|
|
563
|
+
`[webjs] the function in ${attr} on <${tag || 'form'}> is not a server action. `
|
|
564
|
+
+ `Only a function imported from a 'use server' *.server.{js,ts} module can be `
|
|
565
|
+
+ `bound to a form or a submitter, because the identity the browser submits `
|
|
566
|
+
+ `has to resolve back to something the server can run. Move the handler into `
|
|
567
|
+
+ `a server action and import it, or pass a url string.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The enctypes a form submission can actually be parsed from. `text/plain` is
|
|
573
|
+
* legal HTML and useless here: the server parses a submission as multipart or
|
|
574
|
+
* urlencoded, so a `text/plain` bound form runs under JS (the router sends
|
|
575
|
+
* FormData) and is a bare 405 without it. That is precisely the
|
|
576
|
+
* works-one-way-only near-miss this module refuses everywhere else, so it is
|
|
577
|
+
* refused here too rather than silently corrected.
|
|
578
|
+
*/
|
|
579
|
+
export const PARSEABLE_ENCTYPES = new Set(['multipart/form-data', 'application/x-www-form-urlencoded']);
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The shared refusal for a bound form whose own attributes contradict the
|
|
583
|
+
* binding. Kept in one place because SSR reads the emitted start tag and the
|
|
584
|
+
* client reads the live element, and the two must refuse identically.
|
|
585
|
+
* @param {string | null | undefined} method
|
|
586
|
+
* @param {string | null | undefined} enctype
|
|
587
|
+
*/
|
|
588
|
+
export function assertSubmittableForm(method, enctype) {
|
|
589
|
+
// Compared UNTRIMMED on purpose. `method` and `enctype` are enumerated
|
|
590
|
+
// attributes the browser matches against exact keywords with no whitespace
|
|
591
|
+
// stripping, so `method=" post "` falls to the invalid-value default and the
|
|
592
|
+
// form submits as a GET. Trimming here would accept the padded value, emit it
|
|
593
|
+
// untouched, and produce exactly the silently-posts-nowhere form this module
|
|
594
|
+
// exists to make impossible.
|
|
595
|
+
if (method != null && !/^post$/i.test(method)) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`[webjs] <form method="${method}" action=\${action}> cannot work: a bound `
|
|
598
|
+
+ `server action is submitted as a POST body, and a "${method}" form sends `
|
|
599
|
+
+ `no body. Drop the method attribute (WebJs emits method="post") or `
|
|
600
|
+
+ `remove the bound action.`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`[webjs] <form enctype="${enctype}" action=\${action}> cannot work: a form `
|
|
606
|
+
+ `submission is parsed as multipart/form-data or `
|
|
607
|
+
+ `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so `
|
|
608
|
+
+ `the submission would run under JS and do nothing without it. Drop the `
|
|
609
|
+
+ `enctype attribute (WebJs emits multipart/form-data).`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Rewrite a form's START TAG for a bound action and produce the hidden field
|
|
616
|
+
* that must follow it.
|
|
617
|
+
*
|
|
618
|
+
* `startTag` is the already-committed `<form ...>` text, holes included, so
|
|
619
|
+
* this reads the attributes the browser will actually see rather than the ones
|
|
620
|
+
* the template literal spelled out. That matters for a hole-provided
|
|
621
|
+
* `method=${m}`, which no scan of the source template could resolve.
|
|
622
|
+
*
|
|
623
|
+
* Three edits, none of them optional:
|
|
624
|
+
* - `action` is gone already (the caller drops it at the hole), so the form
|
|
625
|
+
* posts to the page's own url. Omitted rather than emitted as `action=""`,
|
|
626
|
+
* which the HTML spec treats as a conformance error.
|
|
627
|
+
* - `method="post"` when absent. A GET form puts its fields in the query
|
|
628
|
+
* string and sends no body, so a bound action would never run.
|
|
629
|
+
* - `enctype="multipart/form-data"` when absent, so a file input works on
|
|
630
|
+
* the no-JS path. Text-only fields round-trip identically either way.
|
|
631
|
+
*
|
|
632
|
+
* An explicit `method="get"` throws rather than being silently upgraded: the
|
|
633
|
+
* author wrote two things that cannot both be true, and quietly picking one
|
|
634
|
+
* hides the mistake.
|
|
635
|
+
*
|
|
636
|
+
* @param {string} startTag the emitted start tag, ending in `>`
|
|
637
|
+
* @param {string} id the action identity
|
|
638
|
+
* @returns {{ tag: string, hidden: string }}
|
|
639
|
+
*/
|
|
640
|
+
export function bindFormActionStartTag(startTag, id) {
|
|
641
|
+
const attrs = parseStartTagAttrs(startTag);
|
|
642
|
+
// The hole's own `action` is already gone. Anything still here is a SECOND,
|
|
643
|
+
// static one, which SSR would emit and the client would remove.
|
|
644
|
+
assertConvergentBoundForm({ staticAction: attrs.has('action') });
|
|
645
|
+
const resolved = resolveBoundFormAttrs(
|
|
646
|
+
attrs.has('method') ? attrs.get('method') : ABSENT,
|
|
647
|
+
attrs.has('enctype') ? attrs.get('enctype') : ABSENT,
|
|
648
|
+
);
|
|
649
|
+
// The close is `>` or `/>`; a self-closing form is not valid HTML but the
|
|
650
|
+
// scanner can still hand one over, so keep whichever the author wrote.
|
|
651
|
+
const close = startTag.endsWith('/>') ? '/>' : '>';
|
|
652
|
+
const head = startTag.slice(0, startTag.length - close.length);
|
|
653
|
+
let inject = '';
|
|
654
|
+
if (resolved.method !== null) inject += ` method="${resolved.method}"`;
|
|
655
|
+
if (resolved.enctype !== null) inject += ` enctype="${resolved.enctype}"`;
|
|
656
|
+
return { tag: head + inject + close, hidden: formActionHiddenField(id) };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Refuse the shapes a bound form can be written in that the two renderers can
|
|
661
|
+
* never agree on, so neither ships a form that submits differently with JS than
|
|
662
|
+
* without it.
|
|
663
|
+
*
|
|
664
|
+
* A `.prop` binding on a native element is DROPPED at SSR and applied for real
|
|
665
|
+
* in the browser, where `method` / `enctype` / the `encoding` alias are
|
|
666
|
+
* reflected IDL attributes. Measured: SSR of `<form action=${fn} .method=
|
|
667
|
+
* ${'get'}>` emits `method="post"` while a browser ends at `method="get"`. No
|
|
668
|
+
* amount of reconciliation closes that, because the client cannot un-know an
|
|
669
|
+
* assignment it made and the server cannot know one it never saw. This is the
|
|
670
|
+
* same argument that already refuses `.action` on a form.
|
|
671
|
+
*
|
|
672
|
+
* Two `action=${...}` holes on one form is the other: SSR emits the second as a
|
|
673
|
+
* plain `action` url ALONGSIDE the identity field, which is incoherent, while
|
|
674
|
+
* the client resolves last-wins.
|
|
675
|
+
*
|
|
676
|
+
* A STATIC `action="/url"` alongside the bound hole is the third, and it is the
|
|
677
|
+
* one that fails quietly rather than loudly. SSR only drops the `action` the
|
|
678
|
+
* HOLE wrote, so the static one survives into the emitted start tag, while the
|
|
679
|
+
* client's reconcile calls `removeAttribute('action')` and takes it out. With
|
|
680
|
+
* JS off the browser posts the identity to `/url`; with JS on the router posts
|
|
681
|
+
* to the page. Same template, two different targets.
|
|
682
|
+
*
|
|
683
|
+
* All three are properties of the template, so they are checked only once the
|
|
684
|
+
* value proves the form is really bound. `<form action=${aString} .method=${m}>`
|
|
685
|
+
* is an ordinary form and is left alone.
|
|
686
|
+
*
|
|
687
|
+
* @param {{ staticAction?: boolean, duplicateAction?: boolean, propAttrs?: string[] }} shape
|
|
688
|
+
* @returns {void}
|
|
689
|
+
*/
|
|
690
|
+
export function assertConvergentBoundForm(shape) {
|
|
691
|
+
if (shape.duplicateAction) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
'[webjs] two action=${...} holes were found on one <form>. A form can carry '
|
|
694
|
+
+ 'only one action binding. Move one to a submit button\'s formaction=${action} '
|
|
695
|
+
+ 'or remove the duplicate.',
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
if (shape.staticAction) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
'[webjs] a bound <form action=${action}> also carries a plain action="..." '
|
|
701
|
+
+ 'attribute. A bound form posts to the page\'s own url, so the two say '
|
|
702
|
+
+ 'different things: without JS the browser would post to the written url '
|
|
703
|
+
+ 'and with JS it would not. Drop the action attribute, or drop the binding.',
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const prop = shape.propAttrs && shape.propAttrs[0];
|
|
707
|
+
if (prop) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
`[webjs] a bound <form action=\${action}> also binds .${prop}=. `
|
|
710
|
+
+ 'A property binding on a native element is dropped at SSR and applied in '
|
|
711
|
+
+ 'the browser, so the form would submit one way with JS and another way '
|
|
712
|
+
+ 'without it. Write it as a plain attribute.',
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The sentinel for "this attribute is not present at all", as distinct from
|
|
719
|
+
* present-and-empty. The difference decides the outcome: an ABSENT `method` is
|
|
720
|
+
* supplied by the framework, while `method=""` is a value the author's template
|
|
721
|
+
* produced and cannot submit, so it is refused.
|
|
722
|
+
*/
|
|
723
|
+
export const ABSENT = Symbol('webjs.attr.absent');
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* THE decision both renderers make about a bound form, in one place.
|
|
727
|
+
*
|
|
728
|
+
* Sharing it is the point. SSR reaches it with the attributes parsed off the
|
|
729
|
+
* start tag it just emitted; the client reaches it with the values its template
|
|
730
|
+
* would have emitted, reconstructed per part kind. Because the inputs mean the
|
|
731
|
+
* same thing and the predicate is literally the same function, a shape the two
|
|
732
|
+
* renderers could disagree about has to be a difference in how the inputs were
|
|
733
|
+
* gathered, which is a much smaller surface than two independent guards.
|
|
734
|
+
*
|
|
735
|
+
* Returns what to INJECT for each attribute, or null to leave the author's own
|
|
736
|
+
* value alone. Throws when a value cannot submit at all.
|
|
737
|
+
*
|
|
738
|
+
* @param {string | typeof ABSENT | null} method
|
|
739
|
+
* @param {string | typeof ABSENT | null} enctype
|
|
740
|
+
* @returns {{ method: string | null, enctype: string | null }}
|
|
741
|
+
*/
|
|
742
|
+
export function resolveBoundFormAttrs(method, enctype) {
|
|
743
|
+
const hasMethod = method !== ABSENT && method != null;
|
|
744
|
+
const hasEnctype = enctype !== ABSENT && enctype != null;
|
|
745
|
+
assertSubmittableForm(
|
|
746
|
+
hasMethod ? /** @type string */ (method) : null,
|
|
747
|
+
hasEnctype ? /** @type string */ (enctype) : null,
|
|
748
|
+
);
|
|
749
|
+
return {
|
|
750
|
+
method: hasMethod ? null : 'post',
|
|
751
|
+
enctype: hasEnctype ? null : 'multipart/form-data',
|
|
752
|
+
};
|
|
753
|
+
}
|
|
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
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The hidden field carrying a bound action's identity into the submission.
|
|
838
|
+
* @param {string} id
|
|
839
|
+
* @returns {string}
|
|
840
|
+
*/
|
|
841
|
+
export function formActionHiddenField(id) {
|
|
842
|
+
return `<input type="hidden" name="${FORM_ACTION_FIELD}" value="${escapeAttr(id)}">`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Converge a live `<form>` on what SSR would have emitted for it (#1155).
|
|
847
|
+
*
|
|
848
|
+
* This runs at the END of a commit pass, unconditionally, for every candidate
|
|
849
|
+
* the template recorded. Both of those matter:
|
|
850
|
+
*
|
|
851
|
+
* END of the pass, because whether a form can submit is a fact about the whole
|
|
852
|
+
* start tag, and an attribute written after the action hole has not been
|
|
853
|
+
* committed when that hole commits.
|
|
854
|
+
*
|
|
855
|
+
* UNCONDITIONALLY, because `updateInstance` re-applies only the parts whose own
|
|
856
|
+
* value changed, so anything hanging off the action hole never runs when a
|
|
857
|
+
* SIBLING hole is what moved.
|
|
858
|
+
*
|
|
859
|
+
* The `method` / `enctype` decision is made from the TEMPLATE (the recorded
|
|
860
|
+
* statics plus this pass's values, resolved per part kind), never by reading
|
|
861
|
+
* them back off the DOM. Reading the DOM cannot work: `?method=${false}` and
|
|
862
|
+
* `method=${null}` both leave no attribute, and SSR resolves them to opposite
|
|
863
|
+
* answers (it emits nothing for the first and `method=""` for the second). The
|
|
864
|
+
* template is the only place that distinction survives.
|
|
865
|
+
*
|
|
866
|
+
* A consequence worth stating, because it looks like a hole and is not: a write
|
|
867
|
+
* from OUTSIDE the template (a `ref` callback, `firstUpdated`, author code, an
|
|
868
|
+
* extension) is deliberately not seen here. SSR cannot see such a write either,
|
|
869
|
+
* so ignoring it is what keeps the two renderers in agreement; catching it
|
|
870
|
+
* would make the client refuse state the server never had.
|
|
871
|
+
*
|
|
872
|
+
* @param {HTMLFormElement} form
|
|
873
|
+
* @param {unknown} value the action hole's resolved value
|
|
874
|
+
* @param {string | typeof ABSENT} method what SSR would have emitted
|
|
875
|
+
* @param {string | typeof ABSENT} enctype what SSR would have emitted
|
|
876
|
+
* @param {{ duplicateAction: boolean, propAttrs: string[] }} shape compile-time refusals
|
|
877
|
+
* @returns {void}
|
|
878
|
+
*/
|
|
879
|
+
export function reconcileFormAction(form, value, method, enctype, shape) {
|
|
880
|
+
const id = typeof value === 'function' ? formActionId(value) : null;
|
|
881
|
+
|
|
882
|
+
if (!id) {
|
|
883
|
+
// Not a bound action (a url string, null, or a function the browser stub
|
|
884
|
+
// never stamped). A function that was MEANT as an action still refuses, so
|
|
885
|
+
// a form never silently posts nowhere.
|
|
886
|
+
if (typeof value === 'function') assertIdentifiableAction(null, form.localName);
|
|
887
|
+
releaseFormAction(form, method, enctype, shape.propAttrs);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Shapes SSR and the client can never agree on, refused rather than
|
|
892
|
+
// reconciled, through the same helper SSR calls.
|
|
893
|
+
assertConvergentBoundForm(shape);
|
|
894
|
+
|
|
895
|
+
const resolved = resolveBoundFormAttrs(method, enctype);
|
|
896
|
+
form.removeAttribute('action');
|
|
897
|
+
applyResolvedAttr(form, 'method', method, resolved.method);
|
|
898
|
+
applyResolvedAttr(form, 'enctype', enctype, resolved.enctype);
|
|
899
|
+
ensureIdentityField(form, id);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Write (or leave) one resolved attribute.
|
|
904
|
+
*
|
|
905
|
+
* `inject` non-null means the template supplies nothing and the framework owns
|
|
906
|
+
* this attribute, so it is set. Otherwise the author's own value is already on
|
|
907
|
+
* the element, put there by whichever commit branch owns that hole, and is left
|
|
908
|
+
* exactly as written.
|
|
909
|
+
*
|
|
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
|
|
914
|
+
* @param {string} name
|
|
915
|
+
* @param {string | typeof ABSENT} authored
|
|
916
|
+
* @param {string | null} inject
|
|
917
|
+
*/
|
|
918
|
+
export function applyResolvedAttr(el, name, authored, inject) {
|
|
919
|
+
if (inject !== null) { el.setAttribute(name, inject); return; }
|
|
920
|
+
// The author's value is authoritative. It is already committed, except for a
|
|
921
|
+
// hole whose value is empty, which some branches express by removing the
|
|
922
|
+
// attribute; put it back so the DOM matches what SSR emitted.
|
|
923
|
+
if (authored !== ABSENT && el.getAttribute(name) !== authored) {
|
|
924
|
+
el.setAttribute(name, /** @type string */ (authored));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Ensure the hidden identity field is present, first, and current.
|
|
930
|
+
*
|
|
931
|
+
* FIRST is load-bearing: it puts the field outside every child part's marker
|
|
932
|
+
* range, so a later child update cannot take it out. `firstElementChild` is the
|
|
933
|
+
* fast path because both this function and SSR put it there, and the scan is
|
|
934
|
+
* only a fallback for a form whose children were moved by hand.
|
|
935
|
+
*
|
|
936
|
+
* Written through `setAttribute`, not the `value` IDL property, so the live DOM
|
|
937
|
+
* matches SSR's markup byte for byte. Assigning `.value` sets the input's value
|
|
938
|
+
* and its dirty flag but leaves no `value` CONTENT attribute, so a
|
|
939
|
+
* client-created field would serialize without one, and any consumer reading
|
|
940
|
+
* the markup (a morph, an `outerHTML` snapshot, a test) would see a field SSR
|
|
941
|
+
* always writes in full.
|
|
942
|
+
*
|
|
943
|
+
* @param {HTMLFormElement} form
|
|
944
|
+
* @param {string} id
|
|
945
|
+
*/
|
|
946
|
+
function ensureIdentityField(form, id) {
|
|
947
|
+
const first = form.firstElementChild;
|
|
948
|
+
let field = first && first.localName === 'input'
|
|
949
|
+
&& first.getAttribute('name') === FORM_ACTION_FIELD ? first : null;
|
|
950
|
+
if (!field) {
|
|
951
|
+
for (const el of form.children) {
|
|
952
|
+
if (el.localName === 'input' && el.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
953
|
+
field = el;
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (!field) {
|
|
959
|
+
field = form.ownerDocument.createElement('input');
|
|
960
|
+
field.setAttribute('type', 'hidden');
|
|
961
|
+
field.setAttribute('name', FORM_ACTION_FIELD);
|
|
962
|
+
form.insertBefore(field, form.firstChild);
|
|
963
|
+
}
|
|
964
|
+
if (field.getAttribute('value') !== id) field.setAttribute('value', id);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* SSR: judge one submitter's emitted START TAG, at its `>`.
|
|
969
|
+
*
|
|
970
|
+
* Called from BOTH state machines so the rules cannot drift between them, which
|
|
971
|
+
* is the failure #1154 already had once (a guard shipped in `renderTemplate`
|
|
972
|
+
* and not in `streamTemplate`). Judged at the `>` rather than at the hole
|
|
973
|
+
* because every input is an attribute the author may have written AFTER the
|
|
974
|
+
* binding.
|
|
975
|
+
*
|
|
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.
|
|
981
|
+
*
|
|
982
|
+
* `name` and `value` are judged by COUNT, not by value, because the renderer
|
|
983
|
+
* has already injected its own pair by this point and a browser resolves a
|
|
984
|
+
* duplicate attribute by keeping the first.
|
|
985
|
+
*
|
|
986
|
+
* @param {string} startTag the emitted start tag, ending in `>`
|
|
987
|
+
* @param {string} tag lowercased owner tag
|
|
988
|
+
* @param {{ duplicateAction?: boolean, propAttrs?: string[] }} shape
|
|
989
|
+
* @returns {void}
|
|
990
|
+
*/
|
|
991
|
+
export function assertSubmitterStartTag(startTag, tag, shape) {
|
|
992
|
+
const attrs = parseStartTagAttrs(startTag);
|
|
993
|
+
const type = attrs.has('type') ? attrs.get('type') : null;
|
|
994
|
+
|
|
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);
|
|
1008
|
+
}
|
|
1009
|
+
assertSubmitterType(tag, type);
|
|
1010
|
+
if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Drop a binding whose action hole no longer resolves to an action.
|
|
1015
|
+
*
|
|
1016
|
+
* Both halves matter. The identity field has to go, or the form keeps posting
|
|
1017
|
+
* the old action's identity to whatever it now targets. And the attributes the
|
|
1018
|
+
* framework supplied have to go with it, or a released form keeps a
|
|
1019
|
+
* `method="post" enctype="multipart/form-data"` that SSR does not emit for the
|
|
1020
|
+
* same template.
|
|
1021
|
+
*
|
|
1022
|
+
* Which attributes were framework-supplied is not remembered from the bind, it
|
|
1023
|
+
* is recomputed: an attribute is the framework's exactly when the template
|
|
1024
|
+
* supplies nothing for it on THIS pass. That is why there is no bookkeeping to
|
|
1025
|
+
* go stale.
|
|
1026
|
+
*
|
|
1027
|
+
* A `.method` / `.enctype` / `.encoding` PROPERTY binding is the exception, and
|
|
1028
|
+
* it has to be named rather than inferred. Its write reflects to the content
|
|
1029
|
+
* attribute, but it is not an attribute part, so "the template supplies nothing
|
|
1030
|
+
* for it" reads true and the removal below would wipe the author's own value on
|
|
1031
|
+
* every re-render. The property spelling is refused on a BOUND form, so this is
|
|
1032
|
+
* the only path that can meet one.
|
|
1033
|
+
*
|
|
1034
|
+
* Keeping it does leave the client and SSR holding different attributes for an
|
|
1035
|
+
* UNBOUND `<form action=${url} .method=${'post'}>`: SSR drops `.prop` bindings
|
|
1036
|
+
* on native elements, so it emits no `method`, while the browser reflects one.
|
|
1037
|
+
* That is the ordinary native-property rule applying to an ordinary form, not a
|
|
1038
|
+
* form-action divergence, and it is what the same template does with no action
|
|
1039
|
+
* hole at all. Removing the attribute would "fix" the mismatch only by
|
|
1040
|
+
* destroying the author's binding, which is a worse answer than agreeing with
|
|
1041
|
+
* every other native property on the page.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {HTMLFormElement} form
|
|
1044
|
+
* @param {string | typeof ABSENT} method
|
|
1045
|
+
* @param {string | typeof ABSENT} enctype
|
|
1046
|
+
* @param {string[] | undefined} propAttrs attribute names a property part owns
|
|
1047
|
+
*/
|
|
1048
|
+
function releaseFormAction(form, method, enctype, propAttrs) {
|
|
1049
|
+
const first = form.firstElementChild;
|
|
1050
|
+
if (first && first.localName === 'input' && first.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
1051
|
+
first.remove();
|
|
1052
|
+
} else {
|
|
1053
|
+
for (const el of form.children) {
|
|
1054
|
+
if (el.localName === 'input' && el.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
1055
|
+
el.remove();
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const owned = (name) => (propAttrs || []).some((p) => {
|
|
1061
|
+
const x = String(p).toLowerCase();
|
|
1062
|
+
return x === name || (name === 'enctype' && x === 'encoding');
|
|
1063
|
+
});
|
|
1064
|
+
if (method === ABSENT && !owned('method')) form.removeAttribute('method');
|
|
1065
|
+
if (enctype === ABSENT && !owned('enctype')) form.removeAttribute('enctype');
|
|
1066
|
+
}
|
|
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
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Parse the attributes of an emitted start tag into `name -> value`, where a
|
|
1096
|
+
* valueless attribute maps to the empty string.
|
|
1097
|
+
*
|
|
1098
|
+
* A real tokenizer rather than a regex over the whole tag, because the tag is
|
|
1099
|
+
* emitted HTML and an attribute VALUE can contain anything: `<form
|
|
1100
|
+
* data-note="use method=get here" action=${fn}>` has to report no `method`
|
|
1101
|
+
* attribute, and a regex looking for `method=` reports one. Getting that wrong
|
|
1102
|
+
* means silently skipping the forced `method="post"` and shipping a form that
|
|
1103
|
+
* submits nothing.
|
|
1104
|
+
*
|
|
1105
|
+
* LAST occurrence wins, matching how a Map collapses duplicate keys. That is
|
|
1106
|
+
* deliberately NOT how a browser resolves a duplicate attribute (it keeps the
|
|
1107
|
+
* FIRST and drops the rest), so any rule that turns on duplication asks
|
|
1108
|
+
* `countStartTagAttr` rather than reading a value back from here.
|
|
1109
|
+
*
|
|
1110
|
+
* @param {string} startTag
|
|
1111
|
+
* @returns {Map<string, string>}
|
|
1112
|
+
*/
|
|
1113
|
+
export function parseStartTagAttrs(startTag) {
|
|
1114
|
+
return new Map(parseStartTagAttrEntries(startTag));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Parse emitted start-tag attributes into ordered entries, duplicates kept.
|
|
1119
|
+
*
|
|
1120
|
+
* The renderers inject `name` and `value` onto a bound submitter themselves, so
|
|
1121
|
+
* telling "the author wrote one too" from "this is ours" is a question about
|
|
1122
|
+
* how MANY there are, which a Map cannot answer.
|
|
1123
|
+
*
|
|
1124
|
+
* @param {string} startTag
|
|
1125
|
+
* @returns {[string, string][]}
|
|
1126
|
+
*/
|
|
1127
|
+
function parseStartTagAttrEntries(startTag) {
|
|
1128
|
+
/** @type {[string, string][]} */
|
|
1129
|
+
const entries = [];
|
|
1130
|
+
// Skip `<` and the tag name.
|
|
1131
|
+
let i = 1;
|
|
1132
|
+
while (i < startTag.length && !/[\s/>]/.test(startTag[i])) i++;
|
|
1133
|
+
while (i < startTag.length) {
|
|
1134
|
+
while (i < startTag.length && /[\s/]/.test(startTag[i])) i++;
|
|
1135
|
+
if (i >= startTag.length || startTag[i] === '>') break;
|
|
1136
|
+
let name = '';
|
|
1137
|
+
while (i < startTag.length && !/[\s/>=]/.test(startTag[i])) name += startTag[i++];
|
|
1138
|
+
while (i < startTag.length && /\s/.test(startTag[i])) i++;
|
|
1139
|
+
if (startTag[i] !== '=') {
|
|
1140
|
+
if (name) entries.push([name.toLowerCase(), '']);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
i++; // the `=`
|
|
1144
|
+
while (i < startTag.length && /\s/.test(startTag[i])) i++;
|
|
1145
|
+
let value = '';
|
|
1146
|
+
const q = startTag[i];
|
|
1147
|
+
if (q === '"' || q === "'") {
|
|
1148
|
+
i++;
|
|
1149
|
+
while (i < startTag.length && startTag[i] !== q) value += startTag[i++];
|
|
1150
|
+
i++; // closing quote
|
|
1151
|
+
} else {
|
|
1152
|
+
while (i < startTag.length && !/[\s>]/.test(startTag[i])) value += startTag[i++];
|
|
1153
|
+
}
|
|
1154
|
+
if (name) entries.push([name.toLowerCase(), value]);
|
|
1155
|
+
}
|
|
1156
|
+
return entries;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Count occurrences of an attribute in an emitted start tag.
|
|
1161
|
+
*
|
|
1162
|
+
* A browser resolves duplicate attributes by keeping the FIRST and dropping the
|
|
1163
|
+
* rest, so on a bound submitter the count is what separates "the author also
|
|
1164
|
+
* wrote one" from "this is the identity we injected". Reading the value back
|
|
1165
|
+
* cannot answer it, because the parse collapses duplicates.
|
|
1166
|
+
*
|
|
1167
|
+
* @param {string} startTag
|
|
1168
|
+
* @param {string} wanted
|
|
1169
|
+
* @returns {number}
|
|
1170
|
+
*/
|
|
1171
|
+
export function countStartTagAttr(startTag, wanted) {
|
|
1172
|
+
const name = String(wanted).toLowerCase();
|
|
1173
|
+
return parseStartTagAttrEntries(startTag).filter(([attr]) => attr === name).length;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
85
1176
|
/**
|
|
86
1177
|
* Is `propName` an IDL attribute that `tag` reflects to a content attribute?
|
|
87
1178
|
* @param {string} propName
|
|
@@ -121,7 +1212,7 @@ function reflectsAsFormAction(propName, tag) {
|
|
|
121
1212
|
* @param {Set<unknown>} [seen]
|
|
122
1213
|
* @returns {boolean}
|
|
123
1214
|
*/
|
|
124
|
-
function carriesFunction(val, seen) {
|
|
1215
|
+
export function carriesFunction(val, seen) {
|
|
125
1216
|
if (typeof val === 'function') return true;
|
|
126
1217
|
if (!Array.isArray(val)) return false;
|
|
127
1218
|
const visited = seen || new Set();
|
|
@@ -151,7 +1242,9 @@ function isFormActionAttr(attrName) {
|
|
|
151
1242
|
|
|
152
1243
|
/**
|
|
153
1244
|
* The refusal message. Deliberately never echoes the function's source, which
|
|
154
|
-
* is the very thing being withheld.
|
|
1245
|
+
* is the very thing being withheld. It names the supported shapes, because
|
|
1246
|
+
* every refused shape here is a near-miss of one of them and the author's next
|
|
1247
|
+
* question is what to write instead.
|
|
155
1248
|
* @param {string} attrName
|
|
156
1249
|
* @param {string} [tag]
|
|
157
1250
|
*/
|
|
@@ -159,5 +1252,7 @@ function formActionError(attrName, tag) {
|
|
|
159
1252
|
return `[webjs] a function was interpolated into ${String(attrName).toLowerCase()}= `
|
|
160
1253
|
+ `on <${tag || 'form'}>. Stringifying a function into HTML would leak its `
|
|
161
1254
|
+ `source (a 'use server' action's body included) to every visitor, so this `
|
|
162
|
-
+ `is refused.
|
|
1255
|
+
+ `is refused. To run a server action from a form, write an unquoted `
|
|
1256
|
+
+ `action=\${action} on the <form> itself or a formaction=\${action} on a `
|
|
1257
|
+
+ `<button> inside a bound form. Anywhere else, pass a URL string.`;
|
|
163
1258
|
}
|