@webjsdev/core 0.7.46 → 0.7.48

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