@webjsdev/core 0.7.45 → 0.7.47
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 +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -0
- package/index.d.ts +4 -0
- package/index.js +5 -0
- package/package.json +2 -2
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +137 -2
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1257 -0
- package/src/optimistic.js +7 -2
- package/src/render-client.js +1004 -107
- package/src/render-server.js +847 -70
- package/src/router-client.js +338 -23
- package/src/webjs-config.d.ts +49 -17
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
import { isBindingPrefix } from './binding-prefixes.js';
|
|
2
|
+
import { escapeAttr } from './escape.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
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
|
+
* Refused shapes on a submitter, each because the identity would not arrive:
|
|
47
|
+
* - Its own `name` (static `name="..."` or a `name=${n}` hole) or its own
|
|
48
|
+
* `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
|
+
* - `<input type="image">`, which submits `name.x` / `name.y` coordinates
|
|
54
|
+
* instead of `name=value`, so the identity would never arrive.
|
|
55
|
+
* - A control that is not a submitter at all, where `formaction` is inert.
|
|
56
|
+
* - A `form="other"` attribute, which moves the submitter's form owner out
|
|
57
|
+
* from under the boundness the renderers just checked.
|
|
58
|
+
*
|
|
59
|
+
* Part B of #1207, and the reason it is not scoped to a bound submitter: a
|
|
60
|
+
* submitter's `formmethod` / `formenctype` can defeat a bound form whether or
|
|
61
|
+
* not that button binds its own action, and neither renderer used to see them.
|
|
62
|
+
* `<form action=${fn}><button formenctype="text/plain">` submits fine under JS
|
|
63
|
+
* (the router posts FormData, ignoring `formenctype`) and is a bare 405 without
|
|
64
|
+
* it, which is precisely the works-one-way-only near-miss refused everywhere
|
|
65
|
+
* else here. So `assertSubmitterSubmission` runs for EVERY submitter inside a
|
|
66
|
+
* bound form. Two carve-outs keep it from over-refusing: `formmethod="dialog"`
|
|
67
|
+
* is a native `<dialog>` dismissal that never submits, and a submitter with its
|
|
68
|
+
* own static `formaction="/url"` retargets away from the bound action entirely,
|
|
69
|
+
* so a GET there is the author's business.
|
|
70
|
+
*
|
|
71
|
+
* Both SSR state machines and the client renderer import from here so the
|
|
72
|
+
* rules cannot drift between them. Each renderer commits attribute, boolean
|
|
73
|
+
* and property holes on separate branches, so they have more call sites than
|
|
74
|
+
* it looks like from any one file and are easy to change one at a time. That
|
|
75
|
+
* is why the predicates live here rather than at each site.
|
|
76
|
+
*
|
|
77
|
+
* The second SSR machine (`streamTemplate`) is reached only through
|
|
78
|
+
* `renderToStream(v, { ssr: false })`, which no page render uses: the server
|
|
79
|
+
* renders every page, Suspense included, via `renderToString`. It is kept in
|
|
80
|
+
* lockstep for the public API surface rather than for a live page.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The hidden field carrying a bound action's identity. The server's form
|
|
85
|
+
* dispatcher reads it off the submitted `FormData`; nothing else is
|
|
86
|
+
* authoritative, so a form that lost this field is never dispatched by
|
|
87
|
+
* guesswork.
|
|
88
|
+
*/
|
|
89
|
+
export const FORM_ACTION_FIELD = '__webjs_action';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The property a generated client RPC stub carries its own identity on.
|
|
93
|
+
* Written by the generated stub source (`packages/server/src/actions.js`), read
|
|
94
|
+
* here. A plain string property rather than a symbol because the stub is
|
|
95
|
+
* generated source that only imports the framework's runtime helpers.
|
|
96
|
+
*/
|
|
97
|
+
export const FORM_ACTION_ID_KEY = '$$webjsAction';
|
|
98
|
+
|
|
99
|
+
/** @type {((fn: Function) => (string | null | Promise<string | null>)) | null} */
|
|
100
|
+
let _resolver = null;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Internal: server-only wiring. `@webjsdev/server` installs the resolver that
|
|
104
|
+
* maps a REAL server-action function back to its `<hash>/<fn>` identity, which
|
|
105
|
+
* it knows from the module load hook that registered the function. The browser
|
|
106
|
+
* never calls this: a stub carries its identity on itself, so
|
|
107
|
+
* `formActionId` resolves synchronously there.
|
|
108
|
+
*
|
|
109
|
+
* @param {(fn: Function) => (string | null | Promise<string | null>)} fn
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
export function setFormActionResolver(fn) {
|
|
113
|
+
_resolver = fn;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The identity of an action function, or null when it has none.
|
|
118
|
+
*
|
|
119
|
+
* Synchronous, and that is a constraint rather than a preference: the client
|
|
120
|
+
* renderer commits attributes synchronously, so identity has to be readable
|
|
121
|
+
* without awaiting there. A stub carries its own, so the client path is a
|
|
122
|
+
* property read. On the server the resolver is consulted, and it may answer
|
|
123
|
+
* with a promise, which `resolveFormActionId` awaits and this does not.
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} fn
|
|
126
|
+
* @returns {string | null}
|
|
127
|
+
*/
|
|
128
|
+
export function formActionId(fn) {
|
|
129
|
+
if (typeof fn !== 'function') return null;
|
|
130
|
+
const own = /** @type any */ (fn)[FORM_ACTION_ID_KEY];
|
|
131
|
+
return typeof own === 'string' && own ? own : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The identity of an action function, consulting the server resolver when the
|
|
136
|
+
* function does not carry one. Used by the SSR renderers, which are async.
|
|
137
|
+
*
|
|
138
|
+
* @param {unknown} fn
|
|
139
|
+
* @returns {Promise<string | null>}
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveFormActionId(fn) {
|
|
142
|
+
const own = formActionId(fn);
|
|
143
|
+
if (own) return own;
|
|
144
|
+
if (typeof fn !== 'function' || !_resolver) return null;
|
|
145
|
+
try {
|
|
146
|
+
const id = await _resolver(/** @type {Function} */ (fn));
|
|
147
|
+
return typeof id === 'string' && id ? id : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Is this hole one of the two supported form-action bindings: an unquoted
|
|
155
|
+
* `action=${fn}` on a `<form>`, or an unquoted `formaction=${fn}` on a
|
|
156
|
+
* `<button>` / `<input>`?
|
|
157
|
+
*
|
|
158
|
+
* Unquoted matters in both cases, because quoting turns a binding hole back
|
|
159
|
+
* into a plain attribute the renderer stringifies, and there is no way to bind
|
|
160
|
+
* half an action into a url. The TAG matters because those are the only
|
|
161
|
+
* elements whose `action` / `formaction` submits anything; anywhere else the
|
|
162
|
+
* attribute is inert and binding a function to it means the author expected
|
|
163
|
+
* something that will never happen.
|
|
164
|
+
*
|
|
165
|
+
* Whether the `<button>` is really a SUBMITTER, and whether its form is bound,
|
|
166
|
+
* are checked separately: they depend on attributes that may be written after
|
|
167
|
+
* this hole, so they are judged once the whole start tag is known.
|
|
168
|
+
*
|
|
169
|
+
* @param {unknown} val the hole's resolved value
|
|
170
|
+
* @param {string} attrName the AUTHORED attribute name (no sigil, unquoted branch)
|
|
171
|
+
* @param {string} [tag] lowercased owner tag
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
export function isBoundFormAction(val, attrName, tag) {
|
|
175
|
+
if (typeof val !== 'function') return false;
|
|
176
|
+
const attr = String(attrName).toLowerCase();
|
|
177
|
+
const t = String(tag || '').toLowerCase();
|
|
178
|
+
if (attr === 'action') return t === 'form';
|
|
179
|
+
if (attr === 'formaction') return t === 'button' || t === 'input';
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Refuse a submitter carrying its own `name` alongside `formaction=${fn}`.
|
|
185
|
+
*
|
|
186
|
+
* The identity needs BOTH halves of the submitter's one `name`/`value` pair, so
|
|
187
|
+
* an author `name` has nowhere to go. `allowFrameworkName` is what lets this
|
|
188
|
+
* run twice over the same element without tripping on the framework's own
|
|
189
|
+
* write: the renderers emit `name="__webjs_action"` themselves, and the client
|
|
190
|
+
* re-reconciles an element it already stamped on a previous pass, so a `name`
|
|
191
|
+
* that IS the identity field is ours and not the author's. It is passed false
|
|
192
|
+
* only where the caller has already established the author wrote one too (SSR
|
|
193
|
+
* sees two `name` attributes on the emitted tag; the client recorded a `name`
|
|
194
|
+
* part or a static `name` on the compiled template).
|
|
195
|
+
*
|
|
196
|
+
* @param {string} name
|
|
197
|
+
* @param {string} [tag]
|
|
198
|
+
* @param {boolean} [allowFrameworkName] treat `name="__webjs_action"` as the framework's own
|
|
199
|
+
*/
|
|
200
|
+
export function assertSubmitterHasNoName(name, tag, allowFrameworkName = true) {
|
|
201
|
+
if (!name) return;
|
|
202
|
+
if (name === FORM_ACTION_FIELD && allowFrameworkName) return;
|
|
203
|
+
const found = name === FORM_ACTION_FIELD
|
|
204
|
+
? 'a second "name" attribute'
|
|
205
|
+
: `name="${name}"`;
|
|
206
|
+
throw new Error(
|
|
207
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used when `
|
|
208
|
+
+ `the element already carries a "name" attribute (found ${found}). The `
|
|
209
|
+
+ `action identity is submitted as the button's own name/value pair, so `
|
|
210
|
+
+ `there is no room for a second one. Move the action to the enclosing `
|
|
211
|
+
+ `<form action=\${action}> or drop the submitter's "name".`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Refuse a formaction binding on a control that is not a submitter.
|
|
217
|
+
*
|
|
218
|
+
* `<input type="image">` gets its own message rather than the generic one: it
|
|
219
|
+
* IS a submitter, so "use a submitter" would be unhelpful advice. It submits
|
|
220
|
+
* `name.x` / `name.y` coordinate pairs instead of `name=value`, so the identity
|
|
221
|
+
* would never arrive and the form would look bound while posting nothing.
|
|
222
|
+
*
|
|
223
|
+
* A `<button>` with no `type` is a submitter (submit is the HTML default),
|
|
224
|
+
* which is why an absent type passes here.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} tag
|
|
227
|
+
* @param {string | null | undefined} type
|
|
228
|
+
*/
|
|
229
|
+
export function assertSubmitterType(tag, type) {
|
|
230
|
+
const t = String(tag || '').toLowerCase();
|
|
231
|
+
const value = type == null ? null : String(type).toLowerCase();
|
|
232
|
+
if (isSubmitterType(t, value)) {
|
|
233
|
+
if (t === 'input' && value === 'image') {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`[webjs] formaction=\${action} is not supported on <input type="image"> `
|
|
236
|
+
+ `because image submitters submit coordinate pairs (name.x/name.y) `
|
|
237
|
+
+ `instead of name=value, so the action identity would never arrive. `
|
|
238
|
+
+ `Use a <button> instead.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (t === 'input') {
|
|
242
|
+
// `<input type="submit">` IS a submitter and Part B still judges its
|
|
243
|
+
// `formmethod` / `formenctype`, but it cannot carry a BINDING. The
|
|
244
|
+
// identity has to occupy the submitter's `value`, and on this control
|
|
245
|
+
// `value` is also the visible caption, so binding an action renders a
|
|
246
|
+
// button captioned `a1b2c3d4e5/publishDraft`. The only fix, an author
|
|
247
|
+
// `value="Publish"`, is the very channel the identity needs, so the two
|
|
248
|
+
// requirements cannot both be met. A `<button>` has no such conflict: its
|
|
249
|
+
// label is its children.
|
|
250
|
+
throw new Error(
|
|
251
|
+
`[webjs] formaction=\${action} is not supported on <input type="submit"> `
|
|
252
|
+
+ `because the action identity has to occupy the submitter's "value", `
|
|
253
|
+
+ `which on this control is also its visible label, so the button would `
|
|
254
|
+
+ `render captioned with the action's id and could not be given a real `
|
|
255
|
+
+ `one. Use <button formaction=\${action}>Publish</button>, whose label `
|
|
256
|
+
+ `is its children.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[webjs] formaction=\${action} on <${t}>${value ? ` type="${value}"` : ''} `
|
|
263
|
+
+ `requires a submitter control, and formaction is inert on anything else. `
|
|
264
|
+
+ `Use <button formaction=\${action}>.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Is this tag/type pair a control that SUBMITS its form?
|
|
270
|
+
*
|
|
271
|
+
* Shared by the binding guard and the Part B sweep, which need the same answer
|
|
272
|
+
* for opposite reasons: the first refuses `formaction=${fn}` on a non-submitter,
|
|
273
|
+
* the second only inspects `formmethod` / `formenctype` on a real submitter,
|
|
274
|
+
* where those attributes are not inert.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} tag lowercased
|
|
277
|
+
* @param {string | null | undefined} type lowercased, or null when absent
|
|
278
|
+
* @returns {boolean}
|
|
279
|
+
*/
|
|
280
|
+
export function isSubmitterType(tag, type) {
|
|
281
|
+
const value = type == null ? null : String(type).toLowerCase();
|
|
282
|
+
if (tag === 'button') return value == null || value === '' || value === 'submit';
|
|
283
|
+
if (tag === 'input') return value === 'submit' || value === 'image';
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Refuse an author `value` on a bound submitter, for the same reason as `name`:
|
|
289
|
+
* the identity is a name/value PAIR, so both halves are spoken for.
|
|
290
|
+
* @param {string} tag
|
|
291
|
+
*/
|
|
292
|
+
export function assertSubmitterHasNoValue(tag) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used when `
|
|
295
|
+
+ `the element already carries a "value" attribute. The action identity is `
|
|
296
|
+
+ `submitted as the button's own name/value pair, so both halves are taken. `
|
|
297
|
+
+ `Drop the "value", or dispatch on it from one form-level action instead.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Refuse a static `formaction="/url"` alongside the bound hole.
|
|
303
|
+
*
|
|
304
|
+
* The same failure as a static `action="/url"` on a bound form, one level down:
|
|
305
|
+
* SSR only rewrites the attribute the HOLE wrote, so the static one survives
|
|
306
|
+
* into the emitted tag, while the client's reconcile removes it. With JS off
|
|
307
|
+
* the browser posts the identity to `/url`; with JS on the router posts to the
|
|
308
|
+
* page. Same template, two different targets.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} tag
|
|
311
|
+
*/
|
|
312
|
+
export function assertSubmitterHasNoStaticFormAction(tag) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[webjs] a bound formaction=\${action} on <${tag || 'button'}> cannot also `
|
|
315
|
+
+ `carry a plain formaction attribute: the two name different targets and `
|
|
316
|
+
+ `the renderers resolve them differently. Drop the static attribute.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Refuse a submitter whose form owner is chosen through the `form` attribute.
|
|
322
|
+
*
|
|
323
|
+
* Boundness was just established for the ENCLOSING form. A `form="other"`
|
|
324
|
+
* re-points the submitter at a different one, which may not be bound at all, so
|
|
325
|
+
* the check that passed would have been about the wrong element.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} tag
|
|
328
|
+
*/
|
|
329
|
+
export function assertSubmitterHasNoFormAttribute(tag) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`[webjs] formaction=\${action} on <${tag || 'button'}> cannot be used with `
|
|
332
|
+
+ `a "form" attribute, which re-points the submitter at a form other than `
|
|
333
|
+
+ `the bound one it sits in. Keep the submitter inside its bound <form>.`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Refuse two `formaction=${...}` holes on one submitter, the per-button twin of
|
|
339
|
+
* the duplicate-`action` refusal: SSR would emit the second alongside the
|
|
340
|
+
* identity it already wrote, while the client resolves last-wins.
|
|
341
|
+
*
|
|
342
|
+
* @param {boolean} duplicate
|
|
343
|
+
* @param {string} tag
|
|
344
|
+
*/
|
|
345
|
+
export function assertSingleSubmitterAction(duplicate, tag) {
|
|
346
|
+
if (!duplicate) return;
|
|
347
|
+
throw new Error(
|
|
348
|
+
`[webjs] two formaction=\${action} holes were found on one <${tag || 'button'}>. `
|
|
349
|
+
+ `Only one can win, and the two renderers pick differently, so bind exactly `
|
|
350
|
+
+ `one action per submitter.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
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
|
+
/**
|
|
387
|
+
* The submitter twin of `assertConvergentBoundForm`: refuse a `.prop` spelling
|
|
388
|
+
* on a submitter that the two renderers can never agree on.
|
|
389
|
+
*
|
|
390
|
+
* Same argument, one level down. A `.prop` on a native element is DROPPED at
|
|
391
|
+
* SSR and applied for real in the browser, and on a `<button>` / `<input>` all
|
|
392
|
+
* of `name`, `value`, `formAction`, `formMethod` and `formEnctype` are
|
|
393
|
+
* REFLECTED IDL attributes, so the browser write lands in the content
|
|
394
|
+
* attribute. Measured: `<button .name=${'intent'} formaction=${fn}>` renders
|
|
395
|
+
* clean at SSR (the prop is dropped, so the identity's `name` survives) and
|
|
396
|
+
* then throws on hydration, where `.name` has written `name="intent"` over it.
|
|
397
|
+
* That is the render-on-the-server, crash-on-hydration direction, which is the
|
|
398
|
+
* one failure this module treats as unacceptable.
|
|
399
|
+
*
|
|
400
|
+
* `formMethod` / `formEnctype` are refused on ANY submitter inside a bound
|
|
401
|
+
* form, not only a bound one, for the same reason Part B is: the browser write
|
|
402
|
+
* reflects, so the attribute the server never saw is the one that decides
|
|
403
|
+
* whether the submission can carry the action's body.
|
|
404
|
+
*
|
|
405
|
+
* @param {string[] | undefined} propAttrs attribute names a property part owns
|
|
406
|
+
* @param {string} tag lowercased owner tag
|
|
407
|
+
* @param {boolean} [bound] whether this submitter carries its own action binding
|
|
408
|
+
* @returns {void}
|
|
409
|
+
*/
|
|
410
|
+
export function assertConvergentSubmitter(propAttrs, tag, bound) {
|
|
411
|
+
// On a submitter that binds nothing, `.name` / `.value` are not this module's
|
|
412
|
+
// business: a plain `<button .name=${'intent'}>` inside a bound form is the
|
|
413
|
+
// ordinary one-action-plus-intent-dispatch pattern, and refusing it would
|
|
414
|
+
// reject valid markup. The THREE that can still defeat the enclosing binding
|
|
415
|
+
// are judged there. `formAction` belongs in that set as surely as the other
|
|
416
|
+
// two: SSR drops the prop so the button submits to the page and runs the
|
|
417
|
+
// bound action, while a browser reflects it and the button posts somewhere
|
|
418
|
+
// else entirely. (A STATIC `formaction="/url"` stays fine, because both
|
|
419
|
+
// renderers see it and agree; only the property spelling diverges.)
|
|
420
|
+
const relevant = (propAttrs || []).filter((n) => (bound
|
|
421
|
+
? isSubmitterReflectedProp(n)
|
|
422
|
+
: /^form(action|method|enctype)$/i.test(String(n))));
|
|
423
|
+
const prop = relevant[0];
|
|
424
|
+
if (!prop) return;
|
|
425
|
+
throw new Error(
|
|
426
|
+
`[webjs] a <${tag || 'button'}> submitter binds .${prop}=. That property is a `
|
|
427
|
+
+ `reflected IDL attribute on a submitter, so a property binding is dropped `
|
|
428
|
+
+ `at SSR and written to the attribute in the browser: the page would render `
|
|
429
|
+
+ `on the server and throw on hydration. Write it as a plain attribute.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Is this property name one whose write reflects into a submitter attribute the
|
|
435
|
+
* form-action rules care about?
|
|
436
|
+
*
|
|
437
|
+
* @param {string} name any case, sigil already stripped
|
|
438
|
+
* @returns {boolean}
|
|
439
|
+
*/
|
|
440
|
+
export function isSubmitterReflectedProp(name) {
|
|
441
|
+
const n = String(name).toLowerCase();
|
|
442
|
+
return n === 'name' || n === 'value' || n === 'formaction'
|
|
443
|
+
|| n === 'formmethod' || n === 'formenctype';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Part B of #1207: refuse a submitter whose own `formmethod` / `formenctype`
|
|
448
|
+
* would defeat the bound form it sits in.
|
|
449
|
+
*
|
|
450
|
+
* Runs for EVERY submitter inside a bound form, not only one that binds its own
|
|
451
|
+
* action, because that is where the gap was: `<form action=${fn}><button
|
|
452
|
+
* formenctype="text/plain">` submits fine under JS (the router posts FormData
|
|
453
|
+
* and ignores `formenctype`) and is a bare 405 without it, since the server
|
|
454
|
+
* parses a submission as multipart or urlencoded only. Refusing the VALUES that
|
|
455
|
+
* cannot work, never the attributes themselves, so `formenctype` naming either
|
|
456
|
+
* parseable encoding stays fully supported.
|
|
457
|
+
*
|
|
458
|
+
* Two carve-outs, both cases where the submitter is not submitting to the bound
|
|
459
|
+
* action at all:
|
|
460
|
+
* - `formmethod="dialog"` is a native `<dialog>` dismissal, never a
|
|
461
|
+
* submission, so there is no body for the action to miss. Refused only when
|
|
462
|
+
* the same button also BINDS an action, which is a straight contradiction.
|
|
463
|
+
* - A static `formaction="/url"` retargets the submission away from the
|
|
464
|
+
* page's bound action entirely, so a GET there is an ordinary form the
|
|
465
|
+
* author is entitled to write.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} tag lowercased owner tag, for the message
|
|
468
|
+
* @param {string | null | undefined} formMethod the submitter's `formmethod`, or null
|
|
469
|
+
* @param {string | null | undefined} formEnctype the submitter's `formenctype`, or null
|
|
470
|
+
* @param {{ bound?: boolean, retargeted?: boolean }} [opts]
|
|
471
|
+
*/
|
|
472
|
+
export function assertSubmitterSubmission(tag, formMethod, formEnctype, opts) {
|
|
473
|
+
const bound = !!(opts && opts.bound);
|
|
474
|
+
const retargeted = !!(opts && opts.retargeted);
|
|
475
|
+
const method = formMethod == null ? null : String(formMethod);
|
|
476
|
+
const enctype = formEnctype == null ? null : String(formEnctype);
|
|
477
|
+
const isDialog = method != null && /^dialog$/i.test(method);
|
|
478
|
+
|
|
479
|
+
if (isDialog) {
|
|
480
|
+
if (!bound) return;
|
|
481
|
+
throw new Error(
|
|
482
|
+
`[webjs] formaction=\${action} on <${tag}> cannot be combined with `
|
|
483
|
+
+ `formmethod="dialog", which dismisses a <dialog> instead of submitting, `
|
|
484
|
+
+ `so the bound action would never run. Drop one of the two.`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (retargeted) return;
|
|
488
|
+
|
|
489
|
+
// Compared UNTRIMMED, exactly as `assertSubmittableForm` compares a form's
|
|
490
|
+
// own `method`. These are enumerated attributes a browser matches against
|
|
491
|
+
// exact keywords with no whitespace stripping, so `formmethod=" post "` falls
|
|
492
|
+
// to the invalid-value default and the button submits as a GET. Trimming here
|
|
493
|
+
// would accept the padded value, emit it untouched, and produce exactly the
|
|
494
|
+
// silently-posts-nowhere submitter this refusal exists to make impossible.
|
|
495
|
+
if (method != null && !/^post$/i.test(method)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`[webjs] <${tag} formmethod="${method}"> inside a bound <form action=\${action}> `
|
|
498
|
+
+ `cannot work: a bound server action is submitted as a POST body, and a `
|
|
499
|
+
+ `"${method}" submission sends none, so the action would never run. Drop the `
|
|
500
|
+
+ `formmethod attribute, or point the button elsewhere with a plain `
|
|
501
|
+
+ `formaction="/url".`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`[webjs] <${tag} formenctype="${enctype}"> inside a bound <form action=\${action}> `
|
|
507
|
+
+ `cannot work: a form submission is parsed as multipart/form-data or `
|
|
508
|
+
+ `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so `
|
|
509
|
+
+ `the submission would run under JS and 405 without it. Drop the formenctype `
|
|
510
|
+
+ `attribute (WebJs emits multipart/form-data on the form).`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Refuse to stringify a function interpolated into a form-action attribute.
|
|
517
|
+
*
|
|
518
|
+
* Name-based on purpose (`action`, plus `formaction`, the submit-button
|
|
519
|
+
* override, which leaks identically): a function stringified under these names
|
|
520
|
+
* is never useful on any tag, and other attributes keep today's stringify
|
|
521
|
+
* behaviour so the claim stays narrow. The one supported shape
|
|
522
|
+
* (`isBoundFormAction`) is claimed by the caller BEFORE this runs, so
|
|
523
|
+
* everything reaching here is a shape with no meaning.
|
|
524
|
+
*
|
|
525
|
+
* @param {unknown} val the hole's resolved value
|
|
526
|
+
* @param {string} attrName the attribute being committed (any case)
|
|
527
|
+
* @param {string} [tag] lowercased owner tag, for the error message
|
|
528
|
+
*/
|
|
529
|
+
export function assertNotFunctionActionAttr(val, attrName, tag) {
|
|
530
|
+
if (!carriesFunction(val)) return;
|
|
531
|
+
if (!isFormActionAttr(attrName)) return;
|
|
532
|
+
throw new Error(formActionError(attrName, tag));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Refuse a function in a `.prop` binding, but ONLY where that property is a
|
|
537
|
+
* reflected IDL attribute and therefore really does write the source out.
|
|
538
|
+
*
|
|
539
|
+
* The attribute form leaks on any tag, because an attribute is stringified
|
|
540
|
+
* into the HTML wherever it appears. A PROPERTY is different: it leaks only
|
|
541
|
+
* when the DOM reflects it back to an attribute, and that is per element.
|
|
542
|
+
* `action` reflects on `<form>`; `formAction` reflects on `<button>` and
|
|
543
|
+
* `<input>`. Anywhere else (`<div .action=${fn}>`, `<li .action=${fn}>`, and
|
|
544
|
+
* `.action` on a `<button>`) the property is a plain expando: nothing is
|
|
545
|
+
* stringified and nothing reaches the markup.
|
|
546
|
+
*
|
|
547
|
+
* Gating on "is not a custom element" instead refused all of those, which is a
|
|
548
|
+
* false positive on a binding WebJs supports and that never leaked. A guard
|
|
549
|
+
* that turns a working delegated-command pattern (`<button .action=${() =>
|
|
550
|
+
* save(row)}>`) into a render error is not a narrower claim, it is a wrong one.
|
|
551
|
+
*
|
|
552
|
+
* A custom element is excluded for the same reason rather than a different
|
|
553
|
+
* one: it has no IDL reflection, so its `.action` is an author-defined
|
|
554
|
+
* property and a function is a legitimate value. There is a separate path
|
|
555
|
+
* that reflects on a custom element, a prop declared `reflect: true`, which
|
|
556
|
+
* runs in the setter and never reaches any commit site. It used to write the
|
|
557
|
+
* source, which is why this carve-out was once narrower than it looked.
|
|
558
|
+
* #1169 closed it at the setter, where a function now removes the attribute.
|
|
559
|
+
*
|
|
560
|
+
* That close is not total, so do not read this exclusion as a guarantee the
|
|
561
|
+
* source can never reach a custom element's attribute. `_reflectAttribute`
|
|
562
|
+
* runs a prop's own `converter.toAttribute` BEFORE the function guard, so an
|
|
563
|
+
* author who supplies one still owns what gets written, a stringified
|
|
564
|
+
* function included. That is deliberate (supplying a converter is taking
|
|
565
|
+
* responsibility for the serialization), but it means the guarantee here
|
|
566
|
+
* covers the converter-less case.
|
|
567
|
+
*
|
|
568
|
+
* `<form .action=${fn}>` is refused rather than treated as the supported
|
|
569
|
+
* binding: the supported one is the plain `action=${fn}` attribute, and a
|
|
570
|
+
* `.prop` binding on a native element is dropped at SSR, so accepting it would
|
|
571
|
+
* mean a form that submits under JS and does nothing without it.
|
|
572
|
+
*
|
|
573
|
+
* @param {unknown} val the hole's resolved value
|
|
574
|
+
* @param {string} propName the property being assigned (any case)
|
|
575
|
+
* @param {string} [tag] lowercased owner tag
|
|
576
|
+
*/
|
|
577
|
+
export function assertNotFunctionReflectedActionProp(val, propName, tag) {
|
|
578
|
+
if (!reflectsAsFormAction(propName, tag)) return;
|
|
579
|
+
assertNotFunctionActionAttr(val, propName, tag);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Refuse a bound action the renderer cannot identify.
|
|
584
|
+
*
|
|
585
|
+
* A function reaches here having passed `isBoundFormAction`, so the author
|
|
586
|
+
* meant it as a server action. If identity resolution came back empty it is
|
|
587
|
+
* not one: a local closure, a plain helper, or a `'use server'` module the
|
|
588
|
+
* server never registered. Emitting the form anyway would produce markup that
|
|
589
|
+
* looks right and silently posts nowhere, which is the one outcome a
|
|
590
|
+
* progressive-enhancement form must never have, so this throws instead.
|
|
591
|
+
*
|
|
592
|
+
* @param {string | null} id
|
|
593
|
+
* @param {string} [tag]
|
|
594
|
+
* @returns {string} the identity, narrowed
|
|
595
|
+
*/
|
|
596
|
+
export function assertIdentifiableAction(id, tag) {
|
|
597
|
+
if (id) return id;
|
|
598
|
+
const attr = tag === 'button' || tag === 'input' ? 'formaction=' : 'action=';
|
|
599
|
+
throw new Error(
|
|
600
|
+
`[webjs] the function in ${attr} on <${tag || 'form'}> is not a server action. `
|
|
601
|
+
+ `Only a function imported from a 'use server' *.server.{js,ts} module can be `
|
|
602
|
+
+ `bound to a form or a submitter, because the identity the browser submits `
|
|
603
|
+
+ `has to resolve back to something the server can run. Move the handler into `
|
|
604
|
+
+ `a server action and import it, or pass a url string.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The enctypes a form submission can actually be parsed from. `text/plain` is
|
|
610
|
+
* legal HTML and useless here: the server parses a submission as multipart or
|
|
611
|
+
* urlencoded, so a `text/plain` bound form runs under JS (the router sends
|
|
612
|
+
* FormData) and is a bare 405 without it. That is precisely the
|
|
613
|
+
* works-one-way-only near-miss this module refuses everywhere else, so it is
|
|
614
|
+
* refused here too rather than silently corrected.
|
|
615
|
+
*/
|
|
616
|
+
export const PARSEABLE_ENCTYPES = new Set(['multipart/form-data', 'application/x-www-form-urlencoded']);
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The shared refusal for a bound form whose own attributes contradict the
|
|
620
|
+
* binding. Kept in one place because SSR reads the emitted start tag and the
|
|
621
|
+
* client reads the live element, and the two must refuse identically.
|
|
622
|
+
* @param {string | null | undefined} method
|
|
623
|
+
* @param {string | null | undefined} enctype
|
|
624
|
+
*/
|
|
625
|
+
export function assertSubmittableForm(method, enctype) {
|
|
626
|
+
// Compared UNTRIMMED on purpose. `method` and `enctype` are enumerated
|
|
627
|
+
// attributes the browser matches against exact keywords with no whitespace
|
|
628
|
+
// stripping, so `method=" post "` falls to the invalid-value default and the
|
|
629
|
+
// form submits as a GET. Trimming here would accept the padded value, emit it
|
|
630
|
+
// untouched, and produce exactly the silently-posts-nowhere form this module
|
|
631
|
+
// exists to make impossible.
|
|
632
|
+
if (method != null && !/^post$/i.test(method)) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`[webjs] <form method="${method}" action=\${action}> cannot work: a bound `
|
|
635
|
+
+ `server action is submitted as a POST body, and a "${method}" form sends `
|
|
636
|
+
+ `no body. Drop the method attribute (WebJs emits method="post") or `
|
|
637
|
+
+ `remove the bound action.`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`[webjs] <form enctype="${enctype}" action=\${action}> cannot work: a form `
|
|
643
|
+
+ `submission is parsed as multipart/form-data or `
|
|
644
|
+
+ `application/x-www-form-urlencoded, and a "${enctype}" body is neither, so `
|
|
645
|
+
+ `the submission would run under JS and do nothing without it. Drop the `
|
|
646
|
+
+ `enctype attribute (WebJs emits multipart/form-data).`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Rewrite a form's START TAG for a bound action and produce the hidden field
|
|
653
|
+
* that must follow it.
|
|
654
|
+
*
|
|
655
|
+
* `startTag` is the already-committed `<form ...>` text, holes included, so
|
|
656
|
+
* this reads the attributes the browser will actually see rather than the ones
|
|
657
|
+
* the template literal spelled out. That matters for a hole-provided
|
|
658
|
+
* `method=${m}`, which no scan of the source template could resolve.
|
|
659
|
+
*
|
|
660
|
+
* Three edits, none of them optional:
|
|
661
|
+
* - `action` is gone already (the caller drops it at the hole), so the form
|
|
662
|
+
* posts to the page's own url. Omitted rather than emitted as `action=""`,
|
|
663
|
+
* which the HTML spec treats as a conformance error.
|
|
664
|
+
* - `method="post"` when absent. A GET form puts its fields in the query
|
|
665
|
+
* string and sends no body, so a bound action would never run.
|
|
666
|
+
* - `enctype="multipart/form-data"` when absent, so a file input works on
|
|
667
|
+
* the no-JS path. Text-only fields round-trip identically either way.
|
|
668
|
+
*
|
|
669
|
+
* An explicit `method="get"` throws rather than being silently upgraded: the
|
|
670
|
+
* author wrote two things that cannot both be true, and quietly picking one
|
|
671
|
+
* hides the mistake.
|
|
672
|
+
*
|
|
673
|
+
* @param {string} startTag the emitted start tag, ending in `>`
|
|
674
|
+
* @param {string} id the action identity
|
|
675
|
+
* @returns {{ tag: string, hidden: string }}
|
|
676
|
+
*/
|
|
677
|
+
export function bindFormActionStartTag(startTag, id) {
|
|
678
|
+
const attrs = parseStartTagAttrs(startTag);
|
|
679
|
+
// The hole's own `action` is already gone. Anything still here is a SECOND,
|
|
680
|
+
// static one, which SSR would emit and the client would remove.
|
|
681
|
+
assertConvergentBoundForm({ staticAction: attrs.has('action') });
|
|
682
|
+
const resolved = resolveBoundFormAttrs(
|
|
683
|
+
attrs.has('method') ? attrs.get('method') : ABSENT,
|
|
684
|
+
attrs.has('enctype') ? attrs.get('enctype') : ABSENT,
|
|
685
|
+
);
|
|
686
|
+
// The close is `>` or `/>`; a self-closing form is not valid HTML but the
|
|
687
|
+
// scanner can still hand one over, so keep whichever the author wrote.
|
|
688
|
+
const close = startTag.endsWith('/>') ? '/>' : '>';
|
|
689
|
+
const head = startTag.slice(0, startTag.length - close.length);
|
|
690
|
+
let inject = '';
|
|
691
|
+
if (resolved.method !== null) inject += ` method="${resolved.method}"`;
|
|
692
|
+
if (resolved.enctype !== null) inject += ` enctype="${resolved.enctype}"`;
|
|
693
|
+
return { tag: head + inject + close, hidden: formActionHiddenField(id) };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Refuse the shapes a bound form can be written in that the two renderers can
|
|
698
|
+
* never agree on, so neither ships a form that submits differently with JS than
|
|
699
|
+
* without it.
|
|
700
|
+
*
|
|
701
|
+
* A `.prop` binding on a native element is DROPPED at SSR and applied for real
|
|
702
|
+
* in the browser, where `method` / `enctype` / the `encoding` alias are
|
|
703
|
+
* reflected IDL attributes. Measured: SSR of `<form action=${fn} .method=
|
|
704
|
+
* ${'get'}>` emits `method="post"` while a browser ends at `method="get"`. No
|
|
705
|
+
* amount of reconciliation closes that, because the client cannot un-know an
|
|
706
|
+
* assignment it made and the server cannot know one it never saw. This is the
|
|
707
|
+
* same argument that already refuses `.action` on a form.
|
|
708
|
+
*
|
|
709
|
+
* Two `action=${...}` holes on one form is the other: SSR emits the second as a
|
|
710
|
+
* plain `action` url ALONGSIDE the identity field, which is incoherent, while
|
|
711
|
+
* the client resolves last-wins.
|
|
712
|
+
*
|
|
713
|
+
* A STATIC `action="/url"` alongside the bound hole is the third, and it is the
|
|
714
|
+
* one that fails quietly rather than loudly. SSR only drops the `action` the
|
|
715
|
+
* HOLE wrote, so the static one survives into the emitted start tag, while the
|
|
716
|
+
* client's reconcile calls `removeAttribute('action')` and takes it out. With
|
|
717
|
+
* JS off the browser posts the identity to `/url`; with JS on the router posts
|
|
718
|
+
* to the page. Same template, two different targets.
|
|
719
|
+
*
|
|
720
|
+
* All three are properties of the template, so they are checked only once the
|
|
721
|
+
* value proves the form is really bound. `<form action=${aString} .method=${m}>`
|
|
722
|
+
* is an ordinary form and is left alone.
|
|
723
|
+
*
|
|
724
|
+
* @param {{ staticAction?: boolean, duplicateAction?: boolean, propAttrs?: string[] }} shape
|
|
725
|
+
* @returns {void}
|
|
726
|
+
*/
|
|
727
|
+
export function assertConvergentBoundForm(shape) {
|
|
728
|
+
if (shape.duplicateAction) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
'[webjs] two action=${...} holes were found on one <form>. A form can carry '
|
|
731
|
+
+ 'only one action binding. Move one to a submit button\'s formaction=${action} '
|
|
732
|
+
+ 'or remove the duplicate.',
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (shape.staticAction) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
'[webjs] a bound <form action=${action}> also carries a plain action="..." '
|
|
738
|
+
+ 'attribute. A bound form posts to the page\'s own url, so the two say '
|
|
739
|
+
+ 'different things: without JS the browser would post to the written url '
|
|
740
|
+
+ 'and with JS it would not. Drop the action attribute, or drop the binding.',
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
const prop = shape.propAttrs && shape.propAttrs[0];
|
|
744
|
+
if (prop) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`[webjs] a bound <form action=\${action}> also binds .${prop}=. `
|
|
747
|
+
+ 'A property binding on a native element is dropped at SSR and applied in '
|
|
748
|
+
+ 'the browser, so the form would submit one way with JS and another way '
|
|
749
|
+
+ 'without it. Write it as a plain attribute.',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The sentinel for "this attribute is not present at all", as distinct from
|
|
756
|
+
* present-and-empty. The difference decides the outcome: an ABSENT `method` is
|
|
757
|
+
* supplied by the framework, while `method=""` is a value the author's template
|
|
758
|
+
* produced and cannot submit, so it is refused.
|
|
759
|
+
*/
|
|
760
|
+
export const ABSENT = Symbol('webjs.attr.absent');
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* THE decision both renderers make about a bound form, in one place.
|
|
764
|
+
*
|
|
765
|
+
* Sharing it is the point. SSR reaches it with the attributes parsed off the
|
|
766
|
+
* start tag it just emitted; the client reaches it with the values its template
|
|
767
|
+
* would have emitted, reconstructed per part kind. Because the inputs mean the
|
|
768
|
+
* same thing and the predicate is literally the same function, a shape the two
|
|
769
|
+
* renderers could disagree about has to be a difference in how the inputs were
|
|
770
|
+
* gathered, which is a much smaller surface than two independent guards.
|
|
771
|
+
*
|
|
772
|
+
* Returns what to INJECT for each attribute, or null to leave the author's own
|
|
773
|
+
* value alone. Throws when a value cannot submit at all.
|
|
774
|
+
*
|
|
775
|
+
* @param {string | typeof ABSENT | null} method
|
|
776
|
+
* @param {string | typeof ABSENT | null} enctype
|
|
777
|
+
* @returns {{ method: string | null, enctype: string | null }}
|
|
778
|
+
*/
|
|
779
|
+
export function resolveBoundFormAttrs(method, enctype) {
|
|
780
|
+
const hasMethod = method !== ABSENT && method != null;
|
|
781
|
+
const hasEnctype = enctype !== ABSENT && enctype != null;
|
|
782
|
+
assertSubmittableForm(
|
|
783
|
+
hasMethod ? /** @type string */ (method) : null,
|
|
784
|
+
hasEnctype ? /** @type string */ (enctype) : null,
|
|
785
|
+
);
|
|
786
|
+
return {
|
|
787
|
+
method: hasMethod ? null : 'post',
|
|
788
|
+
enctype: hasEnctype ? null : 'multipart/form-data',
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The hidden field carrying a bound action's identity into the submission.
|
|
794
|
+
* @param {string} id
|
|
795
|
+
* @returns {string}
|
|
796
|
+
*/
|
|
797
|
+
export function formActionHiddenField(id) {
|
|
798
|
+
return `<input type="hidden" name="${FORM_ACTION_FIELD}" value="${escapeAttr(id)}">`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Converge a live `<form>` on what SSR would have emitted for it (#1155).
|
|
803
|
+
*
|
|
804
|
+
* This runs at the END of a commit pass, unconditionally, for every candidate
|
|
805
|
+
* the template recorded. Both of those matter:
|
|
806
|
+
*
|
|
807
|
+
* END of the pass, because whether a form can submit is a fact about the whole
|
|
808
|
+
* start tag, and an attribute written after the action hole has not been
|
|
809
|
+
* committed when that hole commits.
|
|
810
|
+
*
|
|
811
|
+
* UNCONDITIONALLY, because `updateInstance` re-applies only the parts whose own
|
|
812
|
+
* value changed, so anything hanging off the action hole never runs when a
|
|
813
|
+
* SIBLING hole is what moved.
|
|
814
|
+
*
|
|
815
|
+
* The `method` / `enctype` decision is made from the TEMPLATE (the recorded
|
|
816
|
+
* statics plus this pass's values, resolved per part kind), never by reading
|
|
817
|
+
* them back off the DOM. Reading the DOM cannot work: `?method=${false}` and
|
|
818
|
+
* `method=${null}` both leave no attribute, and SSR resolves them to opposite
|
|
819
|
+
* answers (it emits nothing for the first and `method=""` for the second). The
|
|
820
|
+
* template is the only place that distinction survives.
|
|
821
|
+
*
|
|
822
|
+
* A consequence worth stating, because it looks like a hole and is not: a write
|
|
823
|
+
* from OUTSIDE the template (a `ref` callback, `firstUpdated`, author code, an
|
|
824
|
+
* extension) is deliberately not seen here. SSR cannot see such a write either,
|
|
825
|
+
* so ignoring it is what keeps the two renderers in agreement; catching it
|
|
826
|
+
* would make the client refuse state the server never had.
|
|
827
|
+
*
|
|
828
|
+
* @param {HTMLFormElement} form
|
|
829
|
+
* @param {unknown} value the action hole's resolved value
|
|
830
|
+
* @param {string | typeof ABSENT} method what SSR would have emitted
|
|
831
|
+
* @param {string | typeof ABSENT} enctype what SSR would have emitted
|
|
832
|
+
* @param {{ duplicateAction: boolean, propAttrs: string[] }} shape compile-time refusals
|
|
833
|
+
* @returns {void}
|
|
834
|
+
*/
|
|
835
|
+
export function reconcileFormAction(form, value, method, enctype, shape) {
|
|
836
|
+
const id = typeof value === 'function' ? formActionId(value) : null;
|
|
837
|
+
|
|
838
|
+
if (!id) {
|
|
839
|
+
// Not a bound action (a url string, null, or a function the browser stub
|
|
840
|
+
// never stamped). A function that was MEANT as an action still refuses, so
|
|
841
|
+
// a form never silently posts nowhere.
|
|
842
|
+
if (typeof value === 'function') assertIdentifiableAction(null, form.localName);
|
|
843
|
+
releaseFormAction(form, method, enctype, shape.propAttrs);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Shapes SSR and the client can never agree on, refused rather than
|
|
848
|
+
// reconciled, through the same helper SSR calls.
|
|
849
|
+
assertConvergentBoundForm(shape);
|
|
850
|
+
|
|
851
|
+
const resolved = resolveBoundFormAttrs(method, enctype);
|
|
852
|
+
form.removeAttribute('action');
|
|
853
|
+
applyResolvedAttr(form, 'method', method, resolved.method);
|
|
854
|
+
applyResolvedAttr(form, 'enctype', enctype, resolved.enctype);
|
|
855
|
+
ensureIdentityField(form, id);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Write (or leave) one resolved attribute.
|
|
860
|
+
*
|
|
861
|
+
* `inject` non-null means the template supplies nothing and the framework owns
|
|
862
|
+
* this attribute, so it is set. Otherwise the author's own value is already on
|
|
863
|
+
* the element, put there by whichever commit branch owns that hole, and is left
|
|
864
|
+
* exactly as written.
|
|
865
|
+
*
|
|
866
|
+
* @param {Element} form
|
|
867
|
+
* @param {string} name
|
|
868
|
+
* @param {string | typeof ABSENT} authored
|
|
869
|
+
* @param {string | null} inject
|
|
870
|
+
*/
|
|
871
|
+
function applyResolvedAttr(form, name, authored, inject) {
|
|
872
|
+
if (inject !== null) { form.setAttribute(name, inject); return; }
|
|
873
|
+
// The author's value is authoritative. It is already committed, except for a
|
|
874
|
+
// hole whose value is empty, which some branches express by removing the
|
|
875
|
+
// attribute; put it back so the DOM matches what SSR emitted.
|
|
876
|
+
if (authored !== ABSENT && form.getAttribute(name) !== authored) {
|
|
877
|
+
form.setAttribute(name, /** @type string */ (authored));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Ensure the hidden identity field is present, first, and current.
|
|
883
|
+
*
|
|
884
|
+
* FIRST is load-bearing: it puts the field outside every child part's marker
|
|
885
|
+
* range, so a later child update cannot take it out. `firstElementChild` is the
|
|
886
|
+
* fast path because both this function and SSR put it there, and the scan is
|
|
887
|
+
* only a fallback for a form whose children were moved by hand.
|
|
888
|
+
*
|
|
889
|
+
* Written through `setAttribute`, not the `value` IDL property, so the live DOM
|
|
890
|
+
* matches SSR's markup byte for byte. Assigning `.value` sets the input's value
|
|
891
|
+
* and its dirty flag but leaves no `value` CONTENT attribute, so a
|
|
892
|
+
* client-created field would serialize without one, and any consumer reading
|
|
893
|
+
* the markup (a morph, an `outerHTML` snapshot, a test) would see a field SSR
|
|
894
|
+
* always writes in full.
|
|
895
|
+
*
|
|
896
|
+
* @param {HTMLFormElement} form
|
|
897
|
+
* @param {string} id
|
|
898
|
+
*/
|
|
899
|
+
function ensureIdentityField(form, id) {
|
|
900
|
+
const first = form.firstElementChild;
|
|
901
|
+
let field = first && first.localName === 'input'
|
|
902
|
+
&& first.getAttribute('name') === FORM_ACTION_FIELD ? first : null;
|
|
903
|
+
if (!field) {
|
|
904
|
+
for (const el of form.children) {
|
|
905
|
+
if (el.localName === 'input' && el.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
906
|
+
field = el;
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (!field) {
|
|
912
|
+
field = form.ownerDocument.createElement('input');
|
|
913
|
+
field.setAttribute('type', 'hidden');
|
|
914
|
+
field.setAttribute('name', FORM_ACTION_FIELD);
|
|
915
|
+
form.insertBefore(field, form.firstChild);
|
|
916
|
+
}
|
|
917
|
+
if (field.getAttribute('value') !== id) field.setAttribute('value', id);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* SSR: judge one submitter's emitted START TAG, at its `>`.
|
|
922
|
+
*
|
|
923
|
+
* Called from BOTH state machines so the rules cannot drift between them, which
|
|
924
|
+
* is the failure #1154 already had once (a guard shipped in `renderTemplate`
|
|
925
|
+
* and not in `streamTemplate`). Judged at the `>` rather than at the hole
|
|
926
|
+
* because every input is an attribute the author may have written AFTER the
|
|
927
|
+
* binding.
|
|
928
|
+
*
|
|
929
|
+
* Two callers, distinguished by `bound`:
|
|
930
|
+
* - `bound: true`, the tag carried a `formaction=${fn}` hole, so the full
|
|
931
|
+
* refusal set applies.
|
|
932
|
+
* - `bound: false`, an ordinary submitter inside a bound form, which is the
|
|
933
|
+
* Part B sweep and only looks at `formmethod` / `formenctype`.
|
|
934
|
+
*
|
|
935
|
+
* `name` and `value` are judged by COUNT, not by value, because the renderer
|
|
936
|
+
* has already injected its own pair by this point and a browser resolves a
|
|
937
|
+
* duplicate attribute by keeping the first.
|
|
938
|
+
*
|
|
939
|
+
* @param {string} startTag the emitted start tag, ending in `>`
|
|
940
|
+
* @param {string} tag lowercased owner tag
|
|
941
|
+
* @param {{ bound: boolean, duplicateAction?: boolean, propAttrs?: string[] }} shape
|
|
942
|
+
* @returns {void}
|
|
943
|
+
*/
|
|
944
|
+
export function assertSubmitterStartTag(startTag, tag, shape) {
|
|
945
|
+
const bound = !!shape.bound;
|
|
946
|
+
const attrs = parseStartTagAttrs(startTag);
|
|
947
|
+
const type = attrs.has('type') ? attrs.get('type') : null;
|
|
948
|
+
|
|
949
|
+
// Gated on being a real submitter, because these properties reflect on any
|
|
950
|
+
// control: `<input .name=${'q'}>` is an ordinary field inside the form and
|
|
951
|
+
// has nothing to do with the action.
|
|
952
|
+
if (bound || isSubmitterType(tag, type)) assertConvergentSubmitter(shape.propAttrs, tag, bound);
|
|
953
|
+
|
|
954
|
+
if (bound) {
|
|
955
|
+
assertSingleSubmitterAction(!!shape.duplicateAction, tag);
|
|
956
|
+
if (attrs.has('formaction')) assertSubmitterHasNoStaticFormAction(tag);
|
|
957
|
+
if (countStartTagAttr(startTag, 'value') > 1) assertSubmitterHasNoValue(tag);
|
|
958
|
+
if (countStartTagAttr(startTag, 'name') > 1) {
|
|
959
|
+
// The renderer injected exactly one `name`, so a second is the author's.
|
|
960
|
+
// Falling back to FORM_ACTION_FIELD matters for an EMPTY author name
|
|
961
|
+
// (`name=${null}` emits `name=""`): the parse keeps the last duplicate, so
|
|
962
|
+
// reading the value back would find `''` and the guard would wave through
|
|
963
|
+
// a tag carrying two `name` attributes. A browser resolves that by keeping
|
|
964
|
+
// the FIRST, so whichever came first would silently win, and SSR would
|
|
965
|
+
// ship markup the client never produces.
|
|
966
|
+
assertSubmitterHasNoName(attrs.get('name') || FORM_ACTION_FIELD, tag, false);
|
|
967
|
+
}
|
|
968
|
+
assertSubmitterType(tag, type);
|
|
969
|
+
if (attrs.has('form')) assertSubmitterHasNoFormAttribute(tag);
|
|
970
|
+
} else if (!isSubmitterType(tag, type)) {
|
|
971
|
+
// `formmethod` / `formenctype` are inert on anything that does not submit,
|
|
972
|
+
// so a `<input type="text" formenctype="text/plain">` is not a near-miss.
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const formMethod = attrs.has('formmethod') ? attrs.get('formmethod') : null;
|
|
977
|
+
const formEnctype = attrs.has('formenctype') ? attrs.get('formenctype') : null;
|
|
978
|
+
if (formMethod == null && formEnctype == null) return;
|
|
979
|
+
assertSubmitterSubmission(tag, formMethod, formEnctype, {
|
|
980
|
+
bound,
|
|
981
|
+
retargeted: !bound && attrs.has('formaction'),
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Part B of #1207 on the CLIENT: sweep a bound form's submitters for a
|
|
987
|
+
* `formmethod` / `formenctype` that would defeat the binding.
|
|
988
|
+
*
|
|
989
|
+
* SSR reaches the same rule a byte at a time as it emits each start tag inside
|
|
990
|
+
* an open bound form. The client has no such stream, so it sweeps the form once
|
|
991
|
+
* the binding is settled. Scoped to a bound form, which is rare on a page, so
|
|
992
|
+
* the query cost is paid only where the rule applies.
|
|
993
|
+
*
|
|
994
|
+
* Runs AFTER every submitter in the pass has reconciled, which is what makes
|
|
995
|
+
* `bound` and `retargeted` readable off the DOM: a bound submitter has had its
|
|
996
|
+
* `formaction` removed and carries `name="__webjs_action"`, so a surviving
|
|
997
|
+
* `formaction` attribute is the author's own static one.
|
|
998
|
+
*
|
|
999
|
+
* BEST EFFORT in the same sense as the enclosing-form check: a submitter living
|
|
1000
|
+
* in a nested template that has not been inserted yet is simply not in the
|
|
1001
|
+
* query, and SSR is the renderer that sees every page.
|
|
1002
|
+
*
|
|
1003
|
+
* ATTRIBUTE-BASED, which is a second limit worth stating. This reads the live
|
|
1004
|
+
* DOM, so it cannot see a divergence that exists only in the TEMPLATE: a
|
|
1005
|
+
* `.formMethod` / `.formEnctype` / `.formAction` PROPERTY on a submitter that
|
|
1006
|
+
* binds no action of its own is refused by SSR (`assertConvergentSubmitter`
|
|
1007
|
+
* runs there from the parsed start tag) and is invisible here, because the
|
|
1008
|
+
* client builds a record only for a submitter that owns an action hole. Worse
|
|
1009
|
+
* for `.formAction` specifically: a browser reflects it, so the query below
|
|
1010
|
+
* reads a `formaction` attribute and treats the button as deliberately
|
|
1011
|
+
* retargeted, skipping the check.
|
|
1012
|
+
*
|
|
1013
|
+
* Left as is on purpose. It is the server-strict direction, so the page never
|
|
1014
|
+
* ships: SSR renders every page and refuses the shape before a browser sees it.
|
|
1015
|
+
* The only way through is a component that renders ONLY on the client, and
|
|
1016
|
+
* closing it would mean building template records for submitters that bind
|
|
1017
|
+
* nothing, which is a wider change than the hole justifies. The reverse
|
|
1018
|
+
* asymmetry, client refusing what SSR accepts, is the one this module treats as
|
|
1019
|
+
* unacceptable, and none of these are that.
|
|
1020
|
+
*
|
|
1021
|
+
* @param {HTMLFormElement} form
|
|
1022
|
+
* @returns {void}
|
|
1023
|
+
*/
|
|
1024
|
+
export function assertBoundFormSubmitters(form) {
|
|
1025
|
+
for (const el of form.querySelectorAll('button, input')) {
|
|
1026
|
+
const tag = el.localName;
|
|
1027
|
+
if (!isSubmitterType(tag, el.getAttribute('type'))) continue;
|
|
1028
|
+
const formMethod = el.getAttribute('formmethod');
|
|
1029
|
+
const formEnctype = el.getAttribute('formenctype');
|
|
1030
|
+
if (formMethod == null && formEnctype == null) continue;
|
|
1031
|
+
assertSubmitterSubmission(tag, formMethod, formEnctype, {
|
|
1032
|
+
bound: el.getAttribute('name') === FORM_ACTION_FIELD,
|
|
1033
|
+
retargeted: el.hasAttribute('formaction'),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Drop a binding whose action hole no longer resolves to an action.
|
|
1040
|
+
*
|
|
1041
|
+
* Both halves matter. The identity field has to go, or the form keeps posting
|
|
1042
|
+
* the old action's identity to whatever it now targets. And the attributes the
|
|
1043
|
+
* framework supplied have to go with it, or a released form keeps a
|
|
1044
|
+
* `method="post" enctype="multipart/form-data"` that SSR does not emit for the
|
|
1045
|
+
* same template.
|
|
1046
|
+
*
|
|
1047
|
+
* Which attributes were framework-supplied is not remembered from the bind, it
|
|
1048
|
+
* is recomputed: an attribute is the framework's exactly when the template
|
|
1049
|
+
* supplies nothing for it on THIS pass. That is why there is no bookkeeping to
|
|
1050
|
+
* go stale.
|
|
1051
|
+
*
|
|
1052
|
+
* A `.method` / `.enctype` / `.encoding` PROPERTY binding is the exception, and
|
|
1053
|
+
* it has to be named rather than inferred. Its write reflects to the content
|
|
1054
|
+
* attribute, but it is not an attribute part, so "the template supplies nothing
|
|
1055
|
+
* for it" reads true and the removal below would wipe the author's own value on
|
|
1056
|
+
* every re-render. The property spelling is refused on a BOUND form, so this is
|
|
1057
|
+
* the only path that can meet one.
|
|
1058
|
+
*
|
|
1059
|
+
* Keeping it does leave the client and SSR holding different attributes for an
|
|
1060
|
+
* UNBOUND `<form action=${url} .method=${'post'}>`: SSR drops `.prop` bindings
|
|
1061
|
+
* on native elements, so it emits no `method`, while the browser reflects one.
|
|
1062
|
+
* That is the ordinary native-property rule applying to an ordinary form, not a
|
|
1063
|
+
* form-action divergence, and it is what the same template does with no action
|
|
1064
|
+
* hole at all. Removing the attribute would "fix" the mismatch only by
|
|
1065
|
+
* destroying the author's binding, which is a worse answer than agreeing with
|
|
1066
|
+
* every other native property on the page.
|
|
1067
|
+
*
|
|
1068
|
+
* @param {HTMLFormElement} form
|
|
1069
|
+
* @param {string | typeof ABSENT} method
|
|
1070
|
+
* @param {string | typeof ABSENT} enctype
|
|
1071
|
+
* @param {string[] | undefined} propAttrs attribute names a property part owns
|
|
1072
|
+
*/
|
|
1073
|
+
function releaseFormAction(form, method, enctype, propAttrs) {
|
|
1074
|
+
const first = form.firstElementChild;
|
|
1075
|
+
if (first && first.localName === 'input' && first.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
1076
|
+
first.remove();
|
|
1077
|
+
} else {
|
|
1078
|
+
for (const el of form.children) {
|
|
1079
|
+
if (el.localName === 'input' && el.getAttribute('name') === FORM_ACTION_FIELD) {
|
|
1080
|
+
el.remove();
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const owned = (name) => (propAttrs || []).some((p) => {
|
|
1086
|
+
const x = String(p).toLowerCase();
|
|
1087
|
+
return x === name || (name === 'enctype' && x === 'encoding');
|
|
1088
|
+
});
|
|
1089
|
+
if (method === ABSENT && !owned('method')) form.removeAttribute('method');
|
|
1090
|
+
if (enctype === ABSENT && !owned('enctype')) form.removeAttribute('enctype');
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Parse the attributes of an emitted start tag into `name -> value`, where a
|
|
1095
|
+
* valueless attribute maps to the empty string.
|
|
1096
|
+
*
|
|
1097
|
+
* A real tokenizer rather than a regex over the whole tag, because the tag is
|
|
1098
|
+
* emitted HTML and an attribute VALUE can contain anything: `<form
|
|
1099
|
+
* data-note="use method=get here" action=${fn}>` has to report no `method`
|
|
1100
|
+
* attribute, and a regex looking for `method=` reports one. Getting that wrong
|
|
1101
|
+
* means silently skipping the forced `method="post"` and shipping a form that
|
|
1102
|
+
* submits nothing.
|
|
1103
|
+
*
|
|
1104
|
+
* LAST occurrence wins, matching how a Map collapses duplicate keys. That is
|
|
1105
|
+
* deliberately NOT how a browser resolves a duplicate attribute (it keeps the
|
|
1106
|
+
* FIRST and drops the rest), so any rule that turns on duplication asks
|
|
1107
|
+
* `countStartTagAttr` rather than reading a value back from here.
|
|
1108
|
+
*
|
|
1109
|
+
* @param {string} startTag
|
|
1110
|
+
* @returns {Map<string, string>}
|
|
1111
|
+
*/
|
|
1112
|
+
export function parseStartTagAttrs(startTag) {
|
|
1113
|
+
return new Map(parseStartTagAttrEntries(startTag));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Parse emitted start-tag attributes into ordered entries, duplicates kept.
|
|
1118
|
+
*
|
|
1119
|
+
* The renderers inject `name` and `value` onto a bound submitter themselves, so
|
|
1120
|
+
* telling "the author wrote one too" from "this is ours" is a question about
|
|
1121
|
+
* how MANY there are, which a Map cannot answer.
|
|
1122
|
+
*
|
|
1123
|
+
* @param {string} startTag
|
|
1124
|
+
* @returns {[string, string][]}
|
|
1125
|
+
*/
|
|
1126
|
+
function parseStartTagAttrEntries(startTag) {
|
|
1127
|
+
/** @type {[string, string][]} */
|
|
1128
|
+
const entries = [];
|
|
1129
|
+
// Skip `<` and the tag name.
|
|
1130
|
+
let i = 1;
|
|
1131
|
+
while (i < startTag.length && !/[\s/>]/.test(startTag[i])) i++;
|
|
1132
|
+
while (i < startTag.length) {
|
|
1133
|
+
while (i < startTag.length && /[\s/]/.test(startTag[i])) i++;
|
|
1134
|
+
if (i >= startTag.length || startTag[i] === '>') break;
|
|
1135
|
+
let name = '';
|
|
1136
|
+
while (i < startTag.length && !/[\s/>=]/.test(startTag[i])) name += startTag[i++];
|
|
1137
|
+
while (i < startTag.length && /\s/.test(startTag[i])) i++;
|
|
1138
|
+
if (startTag[i] !== '=') {
|
|
1139
|
+
if (name) entries.push([name.toLowerCase(), '']);
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
i++; // the `=`
|
|
1143
|
+
while (i < startTag.length && /\s/.test(startTag[i])) i++;
|
|
1144
|
+
let value = '';
|
|
1145
|
+
const q = startTag[i];
|
|
1146
|
+
if (q === '"' || q === "'") {
|
|
1147
|
+
i++;
|
|
1148
|
+
while (i < startTag.length && startTag[i] !== q) value += startTag[i++];
|
|
1149
|
+
i++; // closing quote
|
|
1150
|
+
} else {
|
|
1151
|
+
while (i < startTag.length && !/[\s>]/.test(startTag[i])) value += startTag[i++];
|
|
1152
|
+
}
|
|
1153
|
+
if (name) entries.push([name.toLowerCase(), value]);
|
|
1154
|
+
}
|
|
1155
|
+
return entries;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Count occurrences of an attribute in an emitted start tag.
|
|
1160
|
+
*
|
|
1161
|
+
* A browser resolves duplicate attributes by keeping the FIRST and dropping the
|
|
1162
|
+
* rest, so on a bound submitter the count is what separates "the author also
|
|
1163
|
+
* wrote one" from "this is the identity we injected". Reading the value back
|
|
1164
|
+
* cannot answer it, because the parse collapses duplicates.
|
|
1165
|
+
*
|
|
1166
|
+
* @param {string} startTag
|
|
1167
|
+
* @param {string} wanted
|
|
1168
|
+
* @returns {number}
|
|
1169
|
+
*/
|
|
1170
|
+
export function countStartTagAttr(startTag, wanted) {
|
|
1171
|
+
const name = String(wanted).toLowerCase();
|
|
1172
|
+
return parseStartTagAttrEntries(startTag).filter(([attr]) => attr === name).length;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Is `propName` an IDL attribute that `tag` reflects to a content attribute?
|
|
1177
|
+
* @param {string} propName
|
|
1178
|
+
* @param {string} [tag]
|
|
1179
|
+
* @returns {boolean}
|
|
1180
|
+
*/
|
|
1181
|
+
function reflectsAsFormAction(propName, tag) {
|
|
1182
|
+
let name = String(propName).toLowerCase();
|
|
1183
|
+
if (isBindingPrefix(name[0])) name = name.slice(1);
|
|
1184
|
+
const owner = String(tag || '').toLowerCase();
|
|
1185
|
+
if (name === 'action') return owner === 'form';
|
|
1186
|
+
if (name === 'formaction') return owner === 'button' || owner === 'input';
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Does stringifying this value expose a function's source?
|
|
1192
|
+
*
|
|
1193
|
+
* The commit sites do `String(val)`, and `Array.prototype.toString` stringifies
|
|
1194
|
+
* each element through `String()` too, so `action=${[serverAction]}` leaks
|
|
1195
|
+
* exactly as `action=${serverAction}` does. Recursive because nested arrays
|
|
1196
|
+
* join the same way.
|
|
1197
|
+
*
|
|
1198
|
+
* Tracks visited arrays because `Array.prototype.join` has a cycle guard and
|
|
1199
|
+
* this has to match it. A self-referential array stringifies to `''` rather
|
|
1200
|
+
* than recursing forever, so a naive walk would turn a render that used to
|
|
1201
|
+
* succeed into a stack overflow. Refusing to leak must not become a new way
|
|
1202
|
+
* to crash.
|
|
1203
|
+
*
|
|
1204
|
+
* Deliberately NOT a check on the stringified result: sniffing the output for
|
|
1205
|
+
* something function-shaped would misfire on a legitimate URL, and the value's
|
|
1206
|
+
* shape is the thing actually being claimed. An object with a hand-written
|
|
1207
|
+
* `toString` that returns a function's source is out of scope; that is
|
|
1208
|
+
* deliberate exfiltration, not the accident this guards.
|
|
1209
|
+
*
|
|
1210
|
+
* @param {unknown} val
|
|
1211
|
+
* @param {Set<unknown>} [seen]
|
|
1212
|
+
* @returns {boolean}
|
|
1213
|
+
*/
|
|
1214
|
+
export function carriesFunction(val, seen) {
|
|
1215
|
+
if (typeof val === 'function') return true;
|
|
1216
|
+
if (!Array.isArray(val)) return false;
|
|
1217
|
+
const visited = seen || new Set();
|
|
1218
|
+
if (visited.has(val)) return false;
|
|
1219
|
+
visited.add(val);
|
|
1220
|
+
return val.some((v) => carriesFunction(v, visited));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Is this the name of a form-action attribute?
|
|
1225
|
+
*
|
|
1226
|
+
* Strips a leading binding sigil before comparing. The SSR state machines
|
|
1227
|
+
* accumulate the AUTHORED name, and only the unquoted `after-eq` branch splits
|
|
1228
|
+
* the prefix off, so a quoted hole arrives here as `.action` / `?action` /
|
|
1229
|
+
* `@action` with the sigil still attached. Comparing the raw name let every one
|
|
1230
|
+
* of those through, which is the same leak wearing a different hat: the
|
|
1231
|
+
* renderer treats a quoted binding hole as a plain attribute and stringifies it.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {string} attrName
|
|
1234
|
+
* @returns {boolean}
|
|
1235
|
+
*/
|
|
1236
|
+
function isFormActionAttr(attrName) {
|
|
1237
|
+
let name = String(attrName).toLowerCase();
|
|
1238
|
+
if (isBindingPrefix(name[0])) name = name.slice(1);
|
|
1239
|
+
return name === 'action' || name === 'formaction';
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* The refusal message. Deliberately never echoes the function's source, which
|
|
1244
|
+
* is the very thing being withheld. It names the supported shapes, because
|
|
1245
|
+
* every refused shape here is a near-miss of one of them and the author's next
|
|
1246
|
+
* question is what to write instead.
|
|
1247
|
+
* @param {string} attrName
|
|
1248
|
+
* @param {string} [tag]
|
|
1249
|
+
*/
|
|
1250
|
+
function formActionError(attrName, tag) {
|
|
1251
|
+
return `[webjs] a function was interpolated into ${String(attrName).toLowerCase()}= `
|
|
1252
|
+
+ `on <${tag || 'form'}>. Stringifying a function into HTML would leak its `
|
|
1253
|
+
+ `source (a 'use server' action's body included) to every visitor, so this `
|
|
1254
|
+
+ `is refused. To run a server action from a form, write an unquoted `
|
|
1255
|
+
+ `action=\${action} on the <form> itself or a formaction=\${action} on a `
|
|
1256
|
+
+ `<button> inside a bound form. Anywhere else, pass a URL string.`;
|
|
1257
|
+
}
|