@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.
@@ -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
+ }