@webjsdev/core 0.7.46 → 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.
@@ -1,7 +1,14 @@
1
1
  import { html, isTemplate } from './html.js';
2
2
  import { BINDING_PREFIXES } from './binding-prefixes.js';
3
3
  import { escapeText, escapeAttr } from './escape.js';
4
- import { assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp } from './form-action.js';
4
+ import {
5
+ assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
6
+ assertIdentifiableAction, bindFormActionStartTag, isBoundFormAction, resolveFormActionId,
7
+ assertConvergentBoundForm, assertSubmitterHasNoName, assertSubmitterHasNoValue,
8
+ assertSubmitterFormIsBound, assertSubmitterHasNoFormAttribute,
9
+ assertSingleSubmitterAction, assertSubmitterStartTag, parseStartTagAttrs,
10
+ isSubmitterReflectedProp, FORM_ACTION_FIELD,
11
+ } from './form-action.js';
5
12
  import { lookup, lookupModuleUrl, allTags } from './registry.js';
6
13
  import { stylesToString, isCSS } from './css.js';
7
14
  import { isRepeat } from './repeat.js';
@@ -24,10 +31,19 @@ import { cspNonce } from './csp-nonce.js';
24
31
  * Suspense still works but we fall back to emitting only the fallback
25
32
  * (the promise is dropped: appropriate for static pre-render).
26
33
  *
27
- * @typedef {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} SuspenseCtx
34
+ * @typedef {{ pending: {id: string, promise: Promise<unknown>, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} SuspenseCtx
35
+ *
36
+ * `formScope` (#1207) is how a caller that resolves a Suspense boundary ITSELF
37
+ * tells this render what the boundary's surroundings were. The page pipeline in
38
+ * `@webjsdev/server` drains `ctx.pending` and re-renders each resolved child
39
+ * through here, which is a fresh scan with no view of the shell it belongs to,
40
+ * so without the recorded scope a `<button formaction=${fn}>` inside a bound
41
+ * form's Suspense boundary reads as form-less and is refused. That refusal is
42
+ * caught and replaced by the empty string in production, so the whole boundary
43
+ * would silently disappear from a page that still returned 200.
28
44
  *
29
45
  * @param {unknown} value
30
- * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
46
+ * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean, formScope?: 'none'|'unbound'|'bound'|'unknown' }} [opts]
31
47
  * @returns {Promise<string>}
32
48
  */
33
49
  export async function renderToString(value, opts = { ssr: true }) {
@@ -38,20 +54,23 @@ export async function renderToString(value, opts = { ssr: true }) {
38
54
  // sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
39
55
  const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
40
56
  if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
41
- const html = await render(value, ctx);
57
+ // A caller that knows the scope passes it. Absent, this is a top-level render
58
+ // and 'none' is the honest answer: there is conclusively no enclosing form.
59
+ const html = await render(value, ctx, (opts && opts.formScope) || 'none');
42
60
  return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
43
61
  }
44
62
 
45
63
  /**
46
64
  * @param {unknown} value
47
65
  * @param {SuspenseCtx} [ctx]
66
+ * @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
48
67
  * @returns {Promise<string>}
49
68
  */
50
- async function render(value, ctx) {
69
+ async function render(value, ctx, formScope = 'none') {
51
70
  if (value == null || value === false || value === true) return '';
52
71
  if (value && typeof /** @type any */ (value).then === 'function') {
53
72
  value = await value;
54
- return render(value, ctx);
73
+ return render(value, ctx, formScope);
55
74
  }
56
75
  // unsafeHTML: inject raw HTML string without escaping.
57
76
  if (isUnsafeHTML(value)) {
@@ -59,21 +78,21 @@ async function render(value, ctx) {
59
78
  }
60
79
  // live() on the server just unwraps and renders the inner value.
61
80
  if (isLive(value)) {
62
- return render(/** @type any */ (value).value, ctx);
81
+ return render(/** @type any */ (value).value, ctx, formScope);
63
82
  }
64
83
  // watch() on the server reads the signal once and inlines the
65
84
  // result. Subscription is a client-only concern; the SSR HTML
66
85
  // freezes a snapshot of the current value.
67
86
  if (isWatch(value)) {
68
- return render(/** @type any */ (value).signal.get(), ctx);
87
+ return render(/** @type any */ (value).signal.get(), ctx, formScope);
69
88
  }
70
89
  // keyed() on the server: render the wrapped template; key is client-only.
71
90
  if (isKeyed(value)) {
72
- return render(/** @type any */ (value).value, ctx);
91
+ return render(/** @type any */ (value).value, ctx, formScope);
73
92
  }
74
93
  // guard() on the server: always invoke the value function (no cache on SSR).
75
94
  if (isGuard(value)) {
76
- return render(/** @type any */ (value).fn(), ctx);
95
+ return render(/** @type any */ (value).fn(), ctx, formScope);
77
96
  }
78
97
  // templateContent() on the server: emit the template's innerHTML verbatim.
79
98
  if (isTemplateContent(value)) {
@@ -86,7 +105,7 @@ async function render(value, ctx) {
86
105
  }
87
106
  // cache() on the server: pass-through to the inner value.
88
107
  if (isCache(value)) {
89
- return render(/** @type any */ (value).value, ctx);
108
+ return render(/** @type any */ (value).value, ctx, formScope);
90
109
  }
91
110
  // until() on the server: render the first synchronous candidate, or
92
111
  // await the first Promise to settle when all candidates are Promises.
@@ -96,13 +115,13 @@ async function render(value, ctx) {
96
115
  const args = /** @type any */ (value).args;
97
116
  for (const a of args) {
98
117
  if (!a || typeof (/** @type any */ (a).then) !== 'function') {
99
- return render(a, ctx);
118
+ return render(a, ctx, formScope);
100
119
  }
101
120
  }
102
121
  if (args.length > 0) {
103
122
  try {
104
123
  const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
105
- return render(winner, ctx);
124
+ return render(winner, ctx, formScope);
106
125
  } catch {
107
126
  return '';
108
127
  }
@@ -115,34 +134,35 @@ async function render(value, ctx) {
115
134
  return '';
116
135
  }
117
136
  if (Array.isArray(value)) {
118
- const parts = await Promise.all(value.map((v) => render(v, ctx)));
137
+ const parts = await Promise.all(value.map((v) => render(v, ctx, formScope)));
119
138
  return parts.join('');
120
139
  }
121
140
  if (isRepeat(value)) {
122
141
  const r = /** @type any */ (value);
123
- const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx)));
142
+ const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx, formScope)));
124
143
  return parts.join('');
125
144
  }
126
145
  if (isSuspense(value)) {
127
146
  const s = /** @type any */ (value);
128
- const fallback = await render(s.fallback, ctx);
147
+ const fallback = await render(s.fallback, ctx, formScope);
129
148
  if (ctx) {
130
149
  const id = `s${ctx.nextId++}`;
131
- ctx.pending.push({ id, promise: Promise.resolve(s.children) });
150
+ ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
132
151
  return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
133
152
  }
134
153
  return fallback;
135
154
  }
136
- if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
155
+ if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx, formScope);
137
156
  return escapeText(String(value));
138
157
  }
139
158
 
140
159
  /**
141
160
  * @param {import('./html.js').TemplateResult} tr
142
161
  * @param {SuspenseCtx} [ctx]
162
+ * @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
143
163
  * @returns {Promise<string>}
144
164
  */
145
- async function renderTemplate(tr, ctx) {
165
+ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
146
166
  const { strings, values } = tr;
147
167
  let out = '';
148
168
  let state = 'text';
@@ -152,6 +172,135 @@ async function renderTemplate(tr, ctx) {
152
172
  let commentDashes = 0;
153
173
  let currentTag = ''; // lowercased tag name currently being parsed
154
174
  let rawTail = ''; // rolling lowercased tail, tracks </script>/</style>
175
+ let tagStart = -1; // index in `out` of the `<` opening the current tag
176
+ /** @type {string | null} */
177
+ let pendingActionId = null; // identity of a bound form action, until the tag closes
178
+ /** @type {string | null} */
179
+ let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
180
+ // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
181
+ // Collected as the tag is scanned and judged at its `>`, because the action
182
+ // hole may come after them.
183
+ let pendingActionCount = 0;
184
+ /** @type {string[]} */
185
+ let pendingPropAttrs = [];
186
+ /** @type {string[]} */
187
+ let pendingSubmitterProps = [];
188
+ // Whether the tag stream is currently inside a form that BOUND an action
189
+ // (#1207), as THIS scan can see it. Three states, and the third is the point:
190
+ // 'bound' an enclosing <form> opened here and bound an action
191
+ // 'unbound' an enclosing <form> opened here and bound nothing
192
+ // 'none' there is conclusively no enclosing <form>
193
+ // 'unknown' there may be one, but this scan cannot see it
194
+ //
195
+ // The last two look alike and must not be merged, which is what a boolean did.
196
+ // A component's template is rendered by a SEPARATE pass (`injectDSD` calls
197
+ // `render` on it), so a `<button formaction=${fn}>` inside a component inside
198
+ // a bound form read as "no form", was refused, and in production vanished
199
+ // from a page that still returned 200. That pass now says 'unknown' and the
200
+ // boundness question is skipped, exactly as the client skips it when it
201
+ // cannot reach the form, so both renderers are best effort in the same place
202
+ // and for the same reason. A top-level scan that simply contains no form
203
+ // stays 'none' and is still refused, because there the answer IS known.
204
+ //
205
+ // Seeded from the caller so a nested template rendered into a form's children
206
+ // inherits the scope, since each template is its own scan.
207
+ let formScope = formScopeAtStart;
208
+ let isCloseTag = false;
209
+
210
+ // A bound `action=${fn}` is committed at its hole, but the edits it implies
211
+ // (forcing `method` / `enctype`, and the hidden identity field) are only
212
+ // possible once the whole start tag is known: an attribute the author wrote
213
+ // AFTER the action hole still counts, and the hidden field belongs INSIDE
214
+ // the form, after the `>`. So the hole records the identity and this runs at
215
+ // the `>`, rewriting the start tag that was just emitted.
216
+ const closeBoundFormTag = () => {
217
+ // Reset per tag whether or not this one was bound, so a later form is never
218
+ // judged on an earlier tag's shapes.
219
+ const propAttrs = pendingPropAttrs;
220
+ const submitterProps = pendingSubmitterProps;
221
+ const duplicateAction = pendingActionCount > 1;
222
+ const submitterTag = pendingSubmitterTag;
223
+ pendingPropAttrs = [];
224
+ pendingSubmitterProps = [];
225
+ pendingActionCount = 0;
226
+ pendingSubmitterTag = null;
227
+ if (pendingActionId != null) {
228
+ assertConvergentBoundForm({ duplicateAction, propAttrs });
229
+ const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId);
230
+ out = out.slice(0, tagStart) + bound.tag + bound.hidden;
231
+ pendingActionId = null;
232
+ formScope = 'bound';
233
+ }
234
+ // A form that opened and bound NOTHING still opens a scope: a submitter
235
+ // inside it must be refused, which is a different answer from 'none'.
236
+ if (!isCloseTag && currentTag === 'form' && formScope !== 'bound') formScope = 'unbound';
237
+ if (submitterTag != null) {
238
+ assertSubmitterStartTag(out.slice(tagStart), submitterTag, { bound: true, duplicateAction, propAttrs: submitterProps });
239
+ } else if (formScope === 'bound' && !isCloseTag && (currentTag === 'button' || currentTag === 'input')) {
240
+ // Part B (#1207): an ordinary submitter inside a bound form, whose own
241
+ // `formmethod` / `formenctype` can defeat the binding without carrying an
242
+ // action of its own. Neither renderer used to look at those. Gated on
243
+ // 'bound' because that is the only scope in which the sweep has anything
244
+ // to protect: under 'unknown' the enclosing form is not this scan's to
245
+ // judge, and under 'none' / 'unbound' there is no binding to defeat.
246
+ assertSubmitterStartTag(out.slice(tagStart), currentTag, { bound: false, propAttrs: submitterProps });
247
+ }
248
+ };
249
+ // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
250
+ // here but applied for real in the browser, where all three are reflected IDL
251
+ // attributes, so a bound form carrying one submits differently with JS than
252
+ // without it. Recorded and refused at the `>`, once the tag's action hole is
253
+ // known.
254
+ const notePropAttr = (name, tag) => {
255
+ const t = String(tag).toLowerCase();
256
+ if (t === 'button' || t === 'input') {
257
+ // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
258
+ // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
259
+ // dropped here and written to the attribute in the browser.
260
+ if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
261
+ return;
262
+ }
263
+ if (t !== 'form') return;
264
+ let n = String(name).toLowerCase();
265
+ if (n === 'encoding') n = 'enctype';
266
+ if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
267
+ };
268
+ const noteActionHole = (name, tag) => {
269
+ const t = String(tag).toLowerCase();
270
+ const n = String(name).toLowerCase();
271
+ if ((t === 'form' && n === 'action') ||
272
+ ((t === 'button' || t === 'input') && n === 'formaction')) {
273
+ pendingActionCount += 1;
274
+ }
275
+ };
276
+
277
+ // Every `>` in a tag state funnels through here, so the bound-form bookkeeping
278
+ // stays in one place rather than at five call sites. `</form>` closes the form
279
+ // scope: a submitter after it is outside.
280
+ //
281
+ // `allowRawtext` is NOT a preference. Only two of those five call sites ever
282
+ // entered rawtext: the `tag-name` and `in-tag` exits. The three attribute
283
+ // exits (`attr-name`, `after-eq`, `attr-unquoted`) always forced `text`, so
284
+ // `<script defer>` and `<style media=print>`, whose start tags end on a bare
285
+ // or unquoted attribute, escaped their bodies. Switching them to rawtext here
286
+ // would silently turn `<script defer>${userInput}</script>` from escaped into
287
+ // raw script, which is an XSS mitigation this change has no business
288
+ // touching. Whether that escaping is the RIGHT behaviour is a separate
289
+ // question from #1207; this preserves it exactly.
290
+ const handleTagEnd = (allowRawtext) => {
291
+ closeBoundFormTag();
292
+ if (isCloseTag && currentTag === 'form') {
293
+ // Back to the scope this scan STARTED in, not a flat 'none'. Under an
294
+ // 'unknown' seed (the component pass) the scan never learned whether the
295
+ // host page has a bound form, and closing a form of its own teaches it
296
+ // nothing about that, so asserting 'none' here would downgrade a
297
+ // cannot-tell into a conclusive refusal.
298
+ formScope = formScopeAtStart === 'unknown' ? 'unknown' : 'none';
299
+ }
300
+ isCloseTag = false;
301
+ state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
302
+ if (state === 'rawtext') rawTail = '';
303
+ };
155
304
 
156
305
  for (let i = 0; i < strings.length; i++) {
157
306
  const s = strings[i];
@@ -160,12 +309,12 @@ async function renderTemplate(tr, ctx) {
160
309
  switch (state) {
161
310
  case 'text':
162
311
  out += c;
163
- if (c === '<') state = 'tag-open';
312
+ if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; isCloseTag = false; }
164
313
  break;
165
314
  case 'tag-open':
166
315
  out += c;
167
316
  if (c === '!') state = 'bang-1';
168
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
317
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
169
318
  else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
170
319
  else state = 'text';
171
320
  break;
@@ -187,16 +336,14 @@ async function renderTemplate(tr, ctx) {
187
336
  case 'tag-name':
188
337
  out += c;
189
338
  if (c === '>') {
190
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
191
- if (state === 'rawtext') rawTail = '';
339
+ handleTagEnd(true);
192
340
  } else if (/\s/.test(c)) state = 'in-tag';
193
341
  else currentTag += c.toLowerCase();
194
342
  break;
195
343
  case 'in-tag':
196
344
  out += c;
197
345
  if (c === '>') {
198
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
199
- if (state === 'rawtext') rawTail = '';
346
+ handleTagEnd(true);
200
347
  } else if (!/\s/.test(c) && c !== '/') {
201
348
  state = 'attr-name';
202
349
  attrName = c;
@@ -215,18 +362,18 @@ async function renderTemplate(tr, ctx) {
215
362
  case 'attr-name':
216
363
  if (c === '=') { state = 'after-eq'; out += c; }
217
364
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
218
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
365
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
219
366
  else { attrName += c; out += c; }
220
367
  break;
221
368
  case 'after-eq':
222
369
  if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
223
370
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
224
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
371
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
225
372
  else { state = 'attr-unquoted'; out += c; }
226
373
  break;
227
374
  case 'attr-unquoted':
228
375
  if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
229
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
376
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
230
377
  else out += c;
231
378
  break;
232
379
  case 'attr-quoted':
@@ -254,7 +401,7 @@ async function renderTemplate(tr, ctx) {
254
401
  out += String(val ?? '');
255
402
  rawTail = '';
256
403
  } else if (state === 'text') {
257
- out += await render(val, ctx);
404
+ out += await render(val, ctx, formScope);
258
405
  } else if (state === 'after-eq') {
259
406
  const prefix = attrName[0];
260
407
  const name = attrName.slice(1);
@@ -283,7 +430,7 @@ async function renderTemplate(tr, ctx) {
283
430
  // fallback to HTML now and carry it as data-webjs-fallback, which the
284
431
  // injectDSD streaming pre-pass reads as the boundary placeholder.
285
432
  if (currentTag === 'webjs-suspense' && name === 'fallback') {
286
- const fbHtml = await render(val, ctx);
433
+ const fbHtml = await render(val, ctx, formScope);
287
434
  out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
288
435
  state = 'in-tag';
289
436
  attrName = '';
@@ -301,6 +448,7 @@ async function renderTemplate(tr, ctx) {
301
448
  // property is a plain expando that reflects nothing, so refusing
302
449
  // it would be a false positive.
303
450
  assertNotFunctionReflectedActionProp(val, name, currentTag);
451
+ notePropAttr(name, currentTag);
304
452
  state = 'in-tag';
305
453
  attrName = '';
306
454
  continue;
@@ -338,7 +486,55 @@ async function renderTemplate(tr, ctx) {
338
486
  if (val) out += `${name}=""`;
339
487
  state = 'in-tag';
340
488
  attrName = '';
489
+ } else if (isBoundFormAction(val, attrName, currentTag)) {
490
+ noteActionHole(attrName, currentTag);
491
+ if (currentTag === 'form') {
492
+ // #1155: the form-level binding. Drop the `action=` attribute
493
+ // entirely so the form posts to the page's own url (an omitted
494
+ // attribute, not `action=""`, which the spec calls a conformance
495
+ // error), and remember the identity so the `>` can force the
496
+ // submission attributes and emit the hidden field.
497
+ pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
498
+ // Trailing whitespace goes with the attribute: every injected
499
+ // attribute carries its own leading space, so keeping the old one
500
+ // would double it in the emitted tag.
501
+ out = out.slice(0, attrStart).replace(/\s+$/, '');
502
+ } else {
503
+ // #1207: the submitter binding. The identity replaces the
504
+ // `formaction=` hole IN PLACE with the button's own name/value
505
+ // pair, the one channel a browser submits for the pressed button
506
+ // alone. No `formaction` url is emitted, so the submission still
507
+ // targets the page and the form-level identity is simply overridden
508
+ // by this later entry.
509
+ //
510
+ // Refused here rather than at the `>` only where the answer cannot
511
+ // change later: boundness of the ENCLOSING form is already decided
512
+ // (its start tag is emitted), and an attribute written BEFORE the
513
+ // hole is already in `out`. Everything else waits for the close,
514
+ // where `assertSubmitterStartTag` sees the whole tag.
515
+ // A second binding hole on this same tag, refused here so the
516
+ // author gets the duplicate message rather than a confusing
517
+ // complaint about the `name` the FIRST hole just injected.
518
+ assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
519
+ // Only 'unknown' defers: a component renders its own template in a
520
+ // separate pass that cannot see the enclosing form, so the question
521
+ // is not this scan's to answer. 'none' and 'unbound' are known
522
+ // answers and are still refused.
523
+ if (formScope !== 'unknown') assertSubmitterFormIsBound(formScope === 'bound', currentTag);
524
+ const attrs = parseStartTagAttrs(out.slice(tagStart));
525
+ if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
526
+ if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
527
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
528
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
529
+ pendingSubmitterTag = currentTag;
530
+ out = out.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
531
+ }
532
+ state = 'in-tag';
533
+ attrName = '';
341
534
  } else {
535
+ // A second `action` hole that resolved to a plain url still COUNTS,
536
+ // so the duplicate refusal fires whatever the values happen to be.
537
+ noteActionHole(attrName, currentTag);
342
538
  // #1154: never stringify a function into action=/formaction= (it
343
539
  // would serialize a server action's source into the served HTML).
344
540
  assertNotFunctionActionAttr(val, attrName, currentTag);
@@ -864,7 +1060,14 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
864
1060
  // Render the template to HTML. injectDSD recurses on the result so
865
1061
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
866
1062
  // get their own DSD pass.
867
- const rawInner = await render(tpl, ctx);
1063
+ // 'unknown' for the form scope (#1207): this is a SEPARATE render pass
1064
+ // over one component's own template, driven by walking the already-emitted
1065
+ // HTML, so it has no idea whether the host tag sits inside a bound
1066
+ // `<form>`. Passing the default 'none' claimed there was no form at all,
1067
+ // which refused a perfectly good `<button formaction=${fn}>` in a
1068
+ // component inside a bound form and, because component SSR errors are
1069
+ // isolated, made the button vanish from a page that still returned 200.
1070
+ const rawInner = await render(tpl, ctx, 'unknown');
868
1071
 
869
1072
  if (isShadow) {
870
1073
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
@@ -1657,7 +1860,7 @@ export function renderToStream(value, opts = { ssr: true }) {
1657
1860
  try {
1658
1861
  if (opts && opts.ssr === false) {
1659
1862
  // No DSD injection: just stream the raw rendered chunks.
1660
- await streamRender(value, ctx, controller);
1863
+ await streamRender(value, ctx, controller, 'none');
1661
1864
  } else {
1662
1865
  // Render to string first to run DSD injection (which operates on
1663
1866
  // the full HTML), then enqueue the result. This matches the
@@ -1686,28 +1889,29 @@ export function renderToStream(value, opts = { ssr: true }) {
1686
1889
  * @param {unknown} value
1687
1890
  * @param {SuspenseCtx} [ctx]
1688
1891
  * @param {ReadableStreamDefaultController<string>} controller
1892
+ * @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
1689
1893
  */
1690
- async function streamRender(value, ctx, controller) {
1894
+ async function streamRender(value, ctx, controller, formScope = 'none') {
1691
1895
  if (value == null || value === false || value === true) return;
1692
1896
  if (value && typeof /** @type any */ (value).then === 'function') {
1693
1897
  value = await value;
1694
- return streamRender(value, ctx, controller);
1898
+ return streamRender(value, ctx, controller, formScope);
1695
1899
  }
1696
1900
  if (isUnsafeHTML(value)) {
1697
1901
  controller.enqueue(String(/** @type any */ (value).value ?? ''));
1698
1902
  return;
1699
1903
  }
1700
1904
  if (isLive(value)) {
1701
- return streamRender(/** @type any */ (value).value, ctx, controller);
1905
+ return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1702
1906
  }
1703
1907
  if (isWatch(value)) {
1704
- return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
1908
+ return streamRender(/** @type any */ (value).signal.get(), ctx, controller, formScope);
1705
1909
  }
1706
1910
  if (isKeyed(value)) {
1707
- return streamRender(/** @type any */ (value).value, ctx, controller);
1911
+ return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1708
1912
  }
1709
1913
  if (isGuard(value)) {
1710
- return streamRender(/** @type any */ (value).fn(), ctx, controller);
1914
+ return streamRender(/** @type any */ (value).fn(), ctx, controller, formScope);
1711
1915
  }
1712
1916
  if (isTemplateContent(value)) {
1713
1917
  const tpl = /** @type any */ (value).template;
@@ -1718,19 +1922,19 @@ async function streamRender(value, ctx, controller) {
1718
1922
  return;
1719
1923
  }
1720
1924
  if (isCache(value)) {
1721
- return streamRender(/** @type any */ (value).value, ctx, controller);
1925
+ return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1722
1926
  }
1723
1927
  if (isUntil(value)) {
1724
1928
  const args = /** @type any */ (value).args;
1725
1929
  for (const a of args) {
1726
1930
  if (!a || typeof (/** @type any */ (a).then) !== 'function') {
1727
- return streamRender(a, ctx, controller);
1931
+ return streamRender(a, ctx, controller, formScope);
1728
1932
  }
1729
1933
  }
1730
1934
  if (args.length > 0) {
1731
1935
  try {
1732
1936
  const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
1733
- return streamRender(winner, ctx, controller);
1937
+ return streamRender(winner, ctx, controller, formScope);
1734
1938
  } catch {
1735
1939
  return;
1736
1940
  }
@@ -1741,13 +1945,13 @@ async function streamRender(value, ctx, controller) {
1741
1945
  return;
1742
1946
  }
1743
1947
  if (Array.isArray(value)) {
1744
- for (const v of value) await streamRender(v, ctx, controller);
1948
+ for (const v of value) await streamRender(v, ctx, controller, formScope);
1745
1949
  return;
1746
1950
  }
1747
1951
  if (isRepeat(value)) {
1748
1952
  const r = /** @type any */ (value);
1749
1953
  for (let i = 0; i < r.items.length; i++) {
1750
- await streamRender(r.templateFn(r.items[i], i), ctx, controller);
1954
+ await streamRender(r.templateFn(r.items[i], i), ctx, controller, formScope);
1751
1955
  }
1752
1956
  return;
1753
1957
  }
@@ -1756,16 +1960,16 @@ async function streamRender(value, ctx, controller) {
1756
1960
  if (ctx) {
1757
1961
  const id = `s${ctx.nextId++}`;
1758
1962
  controller.enqueue(`<webjs-boundary id="${id}">`);
1759
- await streamRender(s.fallback, ctx, controller);
1963
+ await streamRender(s.fallback, ctx, controller, formScope);
1760
1964
  controller.enqueue(`</webjs-boundary>`);
1761
- ctx.pending.push({ id, promise: Promise.resolve(s.children) });
1965
+ ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
1762
1966
  } else {
1763
- await streamRender(s.fallback, ctx, controller);
1967
+ await streamRender(s.fallback, ctx, controller, formScope);
1764
1968
  }
1765
1969
  return;
1766
1970
  }
1767
1971
  if (isTemplate(value)) {
1768
- await streamTemplate(/** @type any */ (value), ctx, controller);
1972
+ await streamTemplate(/** @type any */ (value), ctx, controller, formScope);
1769
1973
  return;
1770
1974
  }
1771
1975
  controller.enqueue(escapeText(String(value)));
@@ -1778,8 +1982,9 @@ async function streamRender(value, ctx, controller) {
1778
1982
  * @param {import('./html.js').TemplateResult} tr
1779
1983
  * @param {SuspenseCtx} [ctx]
1780
1984
  * @param {ReadableStreamDefaultController<string>} controller
1985
+ * @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
1781
1986
  */
1782
- async function streamTemplate(tr, ctx, controller) {
1987
+ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
1783
1988
  const { strings, values } = tr;
1784
1989
  let state = 'text';
1785
1990
  let attrName = '';
@@ -1790,6 +1995,101 @@ async function streamTemplate(tr, ctx, controller) {
1790
1995
  let rawTail = '';
1791
1996
  // Buffer used for attribute handling where we may need to backtrack.
1792
1997
  let buf = '';
1998
+ let tagStart = -1;
1999
+ /** @type {string | null} */
2000
+ let pendingActionId = null;
2001
+ // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
2002
+ // Collected as the tag is scanned and judged at its `>`, because the action
2003
+ // hole may come after them.
2004
+ let pendingActionCount = 0;
2005
+ /** @type {string[]} */
2006
+ let pendingPropAttrs = [];
2007
+ /** @type {string[]} */
2008
+ let pendingSubmitterProps = [];
2009
+ /** @type {string | null} */
2010
+ let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
2011
+ // See the buffered machine: seeded from the caller so a nested template
2012
+ // rendered into a bound form's children knows it is inside one.
2013
+ let formScope = formScopeAtStart;
2014
+ let isCloseTag = false;
2015
+
2016
+ // See the buffered machine for why this runs at the `>` rather than at the
2017
+ // hole. `tagStart` indexes into `buf`, which is safe because `buf` is only
2018
+ // flushed on a `text`-state hole and a start tag contains none.
2019
+ const closeBoundFormTag = () => {
2020
+ // Reset per tag whether or not this one was bound, so a later form is never
2021
+ // judged on an earlier tag's shapes.
2022
+ const propAttrs = pendingPropAttrs;
2023
+ const submitterProps = pendingSubmitterProps;
2024
+ const duplicateAction = pendingActionCount > 1;
2025
+ const submitterTag = pendingSubmitterTag;
2026
+ pendingPropAttrs = [];
2027
+ pendingSubmitterProps = [];
2028
+ pendingActionCount = 0;
2029
+ pendingSubmitterTag = null;
2030
+ if (pendingActionId != null) {
2031
+ assertConvergentBoundForm({ duplicateAction, propAttrs });
2032
+ const bound = bindFormActionStartTag(buf.slice(tagStart), pendingActionId);
2033
+ buf = buf.slice(0, tagStart) + bound.tag + bound.hidden;
2034
+ pendingActionId = null;
2035
+ formScope = 'bound';
2036
+ }
2037
+ // A form that opened and bound NOTHING still opens a scope: a submitter
2038
+ // inside it must be refused, which is a different answer from 'none'.
2039
+ if (!isCloseTag && currentTag === 'form' && formScope !== 'bound') formScope = 'unbound';
2040
+ if (submitterTag != null) {
2041
+ assertSubmitterStartTag(buf.slice(tagStart), submitterTag, { bound: true, duplicateAction, propAttrs: submitterProps });
2042
+ } else if (formScope === 'bound' && !isCloseTag && (currentTag === 'button' || currentTag === 'input')) {
2043
+ // Part B (#1207), the SAME sweep as the buffered machine, through the
2044
+ // same helper, so this second state machine cannot drift from it.
2045
+ assertSubmitterStartTag(buf.slice(tagStart), currentTag, { bound: false, propAttrs: submitterProps });
2046
+ }
2047
+ };
2048
+ // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
2049
+ // here but applied for real in the browser, where all three are reflected IDL
2050
+ // attributes, so a bound form carrying one submits differently with JS than
2051
+ // without it. Recorded and refused at the `>`, once the tag's action hole is
2052
+ // known.
2053
+ const notePropAttr = (name, tag) => {
2054
+ const t = String(tag).toLowerCase();
2055
+ if (t === 'button' || t === 'input') {
2056
+ // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
2057
+ // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
2058
+ // dropped here and written to the attribute in the browser.
2059
+ if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
2060
+ return;
2061
+ }
2062
+ if (t !== 'form') return;
2063
+ let n = String(name).toLowerCase();
2064
+ if (n === 'encoding') n = 'enctype';
2065
+ if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
2066
+ };
2067
+ const noteActionHole = (name, tag) => {
2068
+ const t = String(tag).toLowerCase();
2069
+ const n = String(name).toLowerCase();
2070
+ if ((t === 'form' && n === 'action') ||
2071
+ ((t === 'button' || t === 'input') && n === 'formaction')) {
2072
+ pendingActionCount += 1;
2073
+ }
2074
+ };
2075
+
2076
+ // Same contract as the buffered machine, `allowRawtext` included: only the
2077
+ // `tag-name` and `in-tag` exits ever entered rawtext here either, so a start
2078
+ // tag ending on a bare or unquoted attribute must keep escaping its body.
2079
+ const handleTagEnd = (allowRawtext) => {
2080
+ closeBoundFormTag();
2081
+ if (isCloseTag && currentTag === 'form') {
2082
+ // Back to the scope this scan STARTED in, not a flat 'none'. Under an
2083
+ // 'unknown' seed (the component pass) the scan never learned whether the
2084
+ // host page has a bound form, and closing a form of its own teaches it
2085
+ // nothing about that, so asserting 'none' here would downgrade a
2086
+ // cannot-tell into a conclusive refusal.
2087
+ formScope = formScopeAtStart === 'unknown' ? 'unknown' : 'none';
2088
+ }
2089
+ isCloseTag = false;
2090
+ state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
2091
+ if (state === 'rawtext') rawTail = '';
2092
+ };
1793
2093
 
1794
2094
  for (let i = 0; i < strings.length; i++) {
1795
2095
  const s = strings[i];
@@ -1798,12 +2098,12 @@ async function streamTemplate(tr, ctx, controller) {
1798
2098
  switch (state) {
1799
2099
  case 'text':
1800
2100
  buf += c;
1801
- if (c === '<') state = 'tag-open';
2101
+ if (c === '<') { state = 'tag-open'; tagStart = buf.length - 1; isCloseTag = false; }
1802
2102
  break;
1803
2103
  case 'tag-open':
1804
2104
  buf += c;
1805
2105
  if (c === '!') state = 'bang-1';
1806
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
2106
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
1807
2107
  else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
1808
2108
  else state = 'text';
1809
2109
  break;
@@ -1825,16 +2125,14 @@ async function streamTemplate(tr, ctx, controller) {
1825
2125
  case 'tag-name':
1826
2126
  buf += c;
1827
2127
  if (c === '>') {
1828
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
1829
- if (state === 'rawtext') rawTail = '';
2128
+ handleTagEnd(true);
1830
2129
  } else if (/\s/.test(c)) state = 'in-tag';
1831
2130
  else currentTag += c.toLowerCase();
1832
2131
  break;
1833
2132
  case 'in-tag':
1834
2133
  buf += c;
1835
2134
  if (c === '>') {
1836
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
1837
- if (state === 'rawtext') rawTail = '';
2135
+ handleTagEnd(true);
1838
2136
  } else if (!/\s/.test(c) && c !== '/') {
1839
2137
  state = 'attr-name';
1840
2138
  attrName = c;
@@ -1853,18 +2151,18 @@ async function streamTemplate(tr, ctx, controller) {
1853
2151
  case 'attr-name':
1854
2152
  if (c === '=') { state = 'after-eq'; buf += c; }
1855
2153
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1856
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2154
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1857
2155
  else { attrName += c; buf += c; }
1858
2156
  break;
1859
2157
  case 'after-eq':
1860
2158
  if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
1861
2159
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1862
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2160
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1863
2161
  else { state = 'attr-unquoted'; buf += c; }
1864
2162
  break;
1865
2163
  case 'attr-unquoted':
1866
2164
  if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1867
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2165
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1868
2166
  else buf += c;
1869
2167
  break;
1870
2168
  case 'attr-quoted':
@@ -1891,7 +2189,7 @@ async function streamTemplate(tr, ctx, controller) {
1891
2189
  } else if (state === 'text') {
1892
2190
  // Flush the buffered static content before streaming the value.
1893
2191
  if (buf) { controller.enqueue(buf); buf = ''; }
1894
- await streamRender(val, ctx, controller);
2192
+ await streamRender(val, ctx, controller, formScope);
1895
2193
  } else if (state === 'after-eq') {
1896
2194
  const prefix = attrName[0];
1897
2195
  const name = attrName.slice(1);
@@ -1905,10 +2203,25 @@ async function streamTemplate(tr, ctx, controller) {
1905
2203
  // (and `.formAction` on a button or input) the client assignment
1906
2204
  // writes the source into the DOM, so refusing at SSR keeps a page
1907
2205
  // from rendering clean on the server and throwing on hydration.
1908
- // Elsewhere, including a custom element, the property reflects
1909
- // nothing and a function stays legal.
2206
+ // Elsewhere the property reflects nothing and a function stays
2207
+ // legal, which is why the check below is gated on a hyphen-free
2208
+ // tag. A custom element is excluded for a different reason than
2209
+ // "it does not reflect": a prop declared `reflect: true` DOES
2210
+ // reflect there, and used to write the source from its own setter.
2211
+ // #1169 guards that at the setter (a function removes the
2212
+ // attribute; an array carrying one does too, except under an
2213
+ // Object/Array type, where JSON drops it losslessly), so it
2214
+ // needs no commit-site check here.
2215
+ //
2216
+ // Unlike the buffered machine this drops EVERY prop, including
2217
+ // `<webjs-suspense .fallback>`: there is no injectDSD pre-pass on
2218
+ // this path (it is reached only through `renderToStream(v, { ssr:
2219
+ // false })`), so there is no consumer for a `data-webjs-fallback`
2220
+ // and emitting one would put an attribute in the markup that nothing
2221
+ // reads.
1910
2222
  if (kind === 'prop' && !currentTag.includes('-')) {
1911
2223
  assertNotFunctionReflectedActionProp(val, name, currentTag);
2224
+ notePropAttr(name, currentTag);
1912
2225
  }
1913
2226
  buf = buf.slice(0, attrStart);
1914
2227
  state = 'in-tag';
@@ -1923,7 +2236,33 @@ async function streamTemplate(tr, ctx, controller) {
1923
2236
  if (val) buf += `${name}=""`;
1924
2237
  state = 'in-tag';
1925
2238
  attrName = '';
2239
+ } else if (isBoundFormAction(val, attrName, currentTag)) {
2240
+ noteActionHole(attrName, currentTag);
2241
+ // The SAME bindings as the buffered renderer (#1155, #1207), in the
2242
+ // second machine, so `renderToStream(v, { ssr: false })` emits an
2243
+ // identical form rather than refusing one the page renderer accepts.
2244
+ if (currentTag === 'form') {
2245
+ pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
2246
+ buf = buf.slice(0, attrStart).replace(/\s+$/, '');
2247
+ } else {
2248
+ assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
2249
+ // Only 'unknown' defers: a component renders its own template in a
2250
+ // separate pass that cannot see the enclosing form, so the question
2251
+ // is not this scan's to answer. 'none' and 'unbound' are known
2252
+ // answers and are still refused.
2253
+ if (formScope !== 'unknown') assertSubmitterFormIsBound(formScope === 'bound', currentTag);
2254
+ const attrs = parseStartTagAttrs(buf.slice(tagStart));
2255
+ if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
2256
+ if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
2257
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
2258
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
2259
+ pendingSubmitterTag = currentTag;
2260
+ buf = buf.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
2261
+ }
2262
+ state = 'in-tag';
2263
+ attrName = '';
1926
2264
  } else {
2265
+ noteActionHole(attrName, currentTag);
1927
2266
  // The SAME guard as the buffered renderer above. This is a second,
1928
2267
  // independent state machine, so it inherits nothing from that one;
1929
2268
  // a change to the rule has to land in both. Reached only via
@@ -1967,10 +2306,10 @@ async function streamSuspenseBoundaries(ctx, controller, dev) {
1967
2306
  while (ctx.pending.length) {
1968
2307
  const batch = ctx.pending.splice(0);
1969
2308
  await Promise.all(
1970
- batch.map(async ({ id, promise }) => {
2309
+ batch.map(async ({ id, promise, formScope }) => {
1971
2310
  try {
1972
2311
  const resolved = await promise;
1973
- const html = await render(resolved, ctx);
2312
+ const html = await render(resolved, ctx, formScope);
1974
2313
  const full = await injectDSD(html, ctx, [], dev);
1975
2314
  controller.enqueue(
1976
2315
  `<template data-webjs-resolve="${id}">${full}</template>` +