@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,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
+ assertSubmitterHasNoFormAttribute,
9
+ assertSingleSubmitterAction, bindSubmitterStartTag, 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';
@@ -26,8 +33,16 @@ import { cspNonce } from './csp-nonce.js';
26
33
  *
27
34
  * @typedef {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} SuspenseCtx
28
35
  *
36
+ * A boundary's enclosing form scope used to be recorded here and threaded back
37
+ * in (#1207), because the page pipeline in `@webjsdev/server` drains
38
+ * `ctx.pending` and re-renders each resolved child through a FRESH scan with no
39
+ * view of the shell it belongs to, so a `<button formaction=${fn}>` inside a
40
+ * bound form's Suspense boundary read as form-less and was refused. Gone with
41
+ * #1307: a bound submitter carries its own `formmethod` and enctype, so no
42
+ * renderer needs to know what encloses it and there is nothing left to thread.
43
+ *
29
44
  * @param {unknown} value
30
- * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
45
+ * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean }} [opts]
31
46
  * @returns {Promise<string>}
32
47
  */
33
48
  export async function renderToString(value, opts = { ssr: true }) {
@@ -152,6 +167,117 @@ async function renderTemplate(tr, ctx) {
152
167
  let commentDashes = 0;
153
168
  let currentTag = ''; // lowercased tag name currently being parsed
154
169
  let rawTail = ''; // rolling lowercased tail, tracks </script>/</style>
170
+ let tagStart = -1; // index in `out` of the `<` opening the current tag
171
+ /** @type {string | null} */
172
+ let pendingActionId = null; // identity of a bound form action, until the tag closes
173
+ /** @type {string | null} */
174
+ let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
175
+ // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
176
+ // Collected as the tag is scanned and judged at its `>`, because the action
177
+ // hole may come after them.
178
+ let pendingActionCount = 0;
179
+ /** @type {string[]} */
180
+ let pendingPropAttrs = [];
181
+ /** @type {string[]} */
182
+ let pendingSubmitterProps = [];
183
+ // Whether the tag stream is currently inside a form that BOUND an action
184
+ // (#1207), as THIS scan can see it. Three states, and the third is the point:
185
+ // 'bound' an enclosing <form> opened here and bound an action
186
+ // 'unbound' an enclosing <form> opened here and bound nothing
187
+ // 'none' there is conclusively no enclosing <form>
188
+ // 'unknown' there may be one, but this scan cannot see it
189
+ //
190
+ // The last two look alike and must not be merged, which is what a boolean did.
191
+ // A component's template is rendered by a SEPARATE pass (`injectDSD` calls
192
+ // `render` on it), so a `<button formaction=${fn}>` inside a component inside
193
+ // a bound form read as "no form", was refused, and in production vanished
194
+ // from a page that still returned 200. That pass now says 'unknown' and the
195
+ // boundness question is skipped, exactly as the client skips it when it
196
+ // cannot reach the form, so both renderers are best effort in the same place
197
+ // and for the same reason. A top-level scan that simply contains no form
198
+ // stays 'none' and is still refused, because there the answer IS known.
199
+ //
200
+ let isCloseTag = false;
201
+
202
+ // A bound `action=${fn}` is committed at its hole, but the edits it implies
203
+ // (forcing `method` / `enctype`, and the hidden identity field) are only
204
+ // possible once the whole start tag is known: an attribute the author wrote
205
+ // AFTER the action hole still counts, and the hidden field belongs INSIDE
206
+ // the form, after the `>`. So the hole records the identity and this runs at
207
+ // the `>`, rewriting the start tag that was just emitted.
208
+ const closeBoundFormTag = () => {
209
+ // Reset per tag whether or not this one was bound, so a later form is never
210
+ // judged on an earlier tag's shapes.
211
+ const propAttrs = pendingPropAttrs;
212
+ const submitterProps = pendingSubmitterProps;
213
+ const duplicateAction = pendingActionCount > 1;
214
+ const submitterTag = pendingSubmitterTag;
215
+ pendingPropAttrs = [];
216
+ pendingSubmitterProps = [];
217
+ pendingActionCount = 0;
218
+ pendingSubmitterTag = null;
219
+ if (pendingActionId != null) {
220
+ assertConvergentBoundForm({ duplicateAction, propAttrs });
221
+ const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId);
222
+ out = out.slice(0, tagStart) + bound.tag + bound.hidden;
223
+ pendingActionId = null;
224
+ }
225
+ if (submitterTag != null) {
226
+ // #1307: a bound submitter carries its WHOLE submission, so `formmethod`
227
+ // and the enctype are injected onto the button here rather than inherited
228
+ // from a form this scan may not even be able to see. That is what removed
229
+ // the enclosing-form question, and with it the four-state scope tracking
230
+ // that could never answer it for a button inside a component.
231
+ out = out.slice(0, tagStart)
232
+ + bindSubmitterStartTag(out.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
233
+ }
234
+ };
235
+ // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
236
+ // here but applied for real in the browser, where all three are reflected IDL
237
+ // attributes, so a bound form carrying one submits differently with JS than
238
+ // without it. Recorded and refused at the `>`, once the tag's action hole is
239
+ // known.
240
+ const notePropAttr = (name, tag) => {
241
+ const t = String(tag).toLowerCase();
242
+ if (t === 'button' || t === 'input') {
243
+ // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
244
+ // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
245
+ // dropped here and written to the attribute in the browser.
246
+ if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
247
+ return;
248
+ }
249
+ if (t !== 'form') return;
250
+ let n = String(name).toLowerCase();
251
+ if (n === 'encoding') n = 'enctype';
252
+ if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
253
+ };
254
+ const noteActionHole = (name, tag) => {
255
+ const t = String(tag).toLowerCase();
256
+ const n = String(name).toLowerCase();
257
+ if ((t === 'form' && n === 'action') ||
258
+ ((t === 'button' || t === 'input') && n === 'formaction')) {
259
+ pendingActionCount += 1;
260
+ }
261
+ };
262
+
263
+ // Every `>` in a tag state funnels through here, so the bound-form bookkeeping
264
+ // stays in one place rather than at five call sites.
265
+ //
266
+ // `allowRawtext` is NOT a preference. Only two of those five call sites ever
267
+ // entered rawtext: the `tag-name` and `in-tag` exits. The three attribute
268
+ // exits (`attr-name`, `after-eq`, `attr-unquoted`) always forced `text`, so
269
+ // `<script defer>` and `<style media=print>`, whose start tags end on a bare
270
+ // or unquoted attribute, escaped their bodies. Switching them to rawtext here
271
+ // would silently turn `<script defer>${userInput}</script>` from escaped into
272
+ // raw script, which is an XSS mitigation this change has no business
273
+ // touching. Whether that escaping is the RIGHT behaviour is a separate
274
+ // question from #1207; this preserves it exactly.
275
+ const handleTagEnd = (allowRawtext) => {
276
+ closeBoundFormTag();
277
+ isCloseTag = false;
278
+ state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
279
+ if (state === 'rawtext') rawTail = '';
280
+ };
155
281
 
156
282
  for (let i = 0; i < strings.length; i++) {
157
283
  const s = strings[i];
@@ -160,12 +286,12 @@ async function renderTemplate(tr, ctx) {
160
286
  switch (state) {
161
287
  case 'text':
162
288
  out += c;
163
- if (c === '<') state = 'tag-open';
289
+ if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; isCloseTag = false; }
164
290
  break;
165
291
  case 'tag-open':
166
292
  out += c;
167
293
  if (c === '!') state = 'bang-1';
168
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
294
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
169
295
  else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
170
296
  else state = 'text';
171
297
  break;
@@ -187,16 +313,14 @@ async function renderTemplate(tr, ctx) {
187
313
  case 'tag-name':
188
314
  out += c;
189
315
  if (c === '>') {
190
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
191
- if (state === 'rawtext') rawTail = '';
316
+ handleTagEnd(true);
192
317
  } else if (/\s/.test(c)) state = 'in-tag';
193
318
  else currentTag += c.toLowerCase();
194
319
  break;
195
320
  case 'in-tag':
196
321
  out += c;
197
322
  if (c === '>') {
198
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
199
- if (state === 'rawtext') rawTail = '';
323
+ handleTagEnd(true);
200
324
  } else if (!/\s/.test(c) && c !== '/') {
201
325
  state = 'attr-name';
202
326
  attrName = c;
@@ -215,18 +339,18 @@ async function renderTemplate(tr, ctx) {
215
339
  case 'attr-name':
216
340
  if (c === '=') { state = 'after-eq'; out += c; }
217
341
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
218
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
342
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
219
343
  else { attrName += c; out += c; }
220
344
  break;
221
345
  case 'after-eq':
222
346
  if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
223
347
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
224
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
348
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
225
349
  else { state = 'attr-unquoted'; out += c; }
226
350
  break;
227
351
  case 'attr-unquoted':
228
352
  if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
229
- else if (c === '>') { state = 'text'; attrName = ''; out += c; }
353
+ else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
230
354
  else out += c;
231
355
  break;
232
356
  case 'attr-quoted':
@@ -301,6 +425,7 @@ async function renderTemplate(tr, ctx) {
301
425
  // property is a plain expando that reflects nothing, so refusing
302
426
  // it would be a false positive.
303
427
  assertNotFunctionReflectedActionProp(val, name, currentTag);
428
+ notePropAttr(name, currentTag);
304
429
  state = 'in-tag';
305
430
  attrName = '';
306
431
  continue;
@@ -338,7 +463,50 @@ async function renderTemplate(tr, ctx) {
338
463
  if (val) out += `${name}=""`;
339
464
  state = 'in-tag';
340
465
  attrName = '';
466
+ } else if (isBoundFormAction(val, attrName, currentTag)) {
467
+ noteActionHole(attrName, currentTag);
468
+ if (currentTag === 'form') {
469
+ // #1155: the form-level binding. Drop the `action=` attribute
470
+ // entirely so the form posts to the page's own url (an omitted
471
+ // attribute, not `action=""`, which the spec calls a conformance
472
+ // error), and remember the identity so the `>` can force the
473
+ // submission attributes and emit the hidden field.
474
+ pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
475
+ // Trailing whitespace goes with the attribute: every injected
476
+ // attribute carries its own leading space, so keeping the old one
477
+ // would double it in the emitted tag.
478
+ out = out.slice(0, attrStart).replace(/\s+$/, '');
479
+ } else {
480
+ // #1207: the submitter binding. The identity replaces the
481
+ // `formaction=` hole IN PLACE with the button's own name/value
482
+ // pair, the one channel a browser submits for the pressed button
483
+ // alone. No `formaction` url is emitted, so the submission still
484
+ // targets the page and the form-level identity is simply overridden
485
+ // by this later entry.
486
+ //
487
+ // Refused here rather than at the `>` only where the answer cannot
488
+ // change later: boundness of the ENCLOSING form is already decided
489
+ // (its start tag is emitted), and an attribute written BEFORE the
490
+ // hole is already in `out`. Everything else waits for the close,
491
+ // where `assertSubmitterStartTag` sees the whole tag.
492
+ // A second binding hole on this same tag, refused here so the
493
+ // author gets the duplicate message rather than a confusing
494
+ // complaint about the `name` the FIRST hole just injected.
495
+ assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
496
+ const attrs = parseStartTagAttrs(out.slice(tagStart));
497
+ if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
498
+ if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
499
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
500
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
501
+ pendingSubmitterTag = currentTag;
502
+ out = out.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
503
+ }
504
+ state = 'in-tag';
505
+ attrName = '';
341
506
  } else {
507
+ // A second `action` hole that resolved to a plain url still COUNTS,
508
+ // so the duplicate refusal fires whatever the values happen to be.
509
+ noteActionHole(attrName, currentTag);
342
510
  // #1154: never stringify a function into action=/formaction= (it
343
511
  // would serialize a server action's source into the served HTML).
344
512
  assertNotFunctionActionAttr(val, attrName, currentTag);
@@ -864,7 +1032,14 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
864
1032
  // Render the template to HTML. injectDSD recurses on the result so
865
1033
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
866
1034
  // get their own DSD pass.
867
- const rawInner = await render(tpl, ctx);
1035
+ // 'unknown' for the form scope (#1207): this is a SEPARATE render pass
1036
+ // over one component's own template, driven by walking the already-emitted
1037
+ // HTML, so it has no idea whether the host tag sits inside a bound
1038
+ // `<form>`. Passing the default 'none' claimed there was no form at all,
1039
+ // which refused a perfectly good `<button formaction=${fn}>` in a
1040
+ // component inside a bound form and, because component SSR errors are
1041
+ // isolated, made the button vanish from a page that still returned 200.
1042
+ const rawInner = await render(tpl, ctx, 'unknown');
868
1043
 
869
1044
  if (isShadow) {
870
1045
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
@@ -1657,7 +1832,7 @@ export function renderToStream(value, opts = { ssr: true }) {
1657
1832
  try {
1658
1833
  if (opts && opts.ssr === false) {
1659
1834
  // No DSD injection: just stream the raw rendered chunks.
1660
- await streamRender(value, ctx, controller);
1835
+ await streamRender(value, ctx, controller, 'none');
1661
1836
  } else {
1662
1837
  // Render to string first to run DSD injection (which operates on
1663
1838
  // the full HTML), then enqueue the result. This matches the
@@ -1790,6 +1965,87 @@ async function streamTemplate(tr, ctx, controller) {
1790
1965
  let rawTail = '';
1791
1966
  // Buffer used for attribute handling where we may need to backtrack.
1792
1967
  let buf = '';
1968
+ let tagStart = -1;
1969
+ /** @type {string | null} */
1970
+ let pendingActionId = null;
1971
+ // Shapes on the CURRENT start tag that a bound form may not carry (#1155).
1972
+ // Collected as the tag is scanned and judged at its `>`, because the action
1973
+ // hole may come after them.
1974
+ let pendingActionCount = 0;
1975
+ /** @type {string[]} */
1976
+ let pendingPropAttrs = [];
1977
+ /** @type {string[]} */
1978
+ let pendingSubmitterProps = [];
1979
+ /** @type {string | null} */
1980
+ let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
1981
+ // See the buffered machine: seeded from the caller so a nested template
1982
+ // rendered into a bound form's children knows it is inside one.
1983
+ let isCloseTag = false;
1984
+
1985
+ // See the buffered machine for why this runs at the `>` rather than at the
1986
+ // hole. `tagStart` indexes into `buf`, which is safe because `buf` is only
1987
+ // flushed on a `text`-state hole and a start tag contains none.
1988
+ const closeBoundFormTag = () => {
1989
+ // Reset per tag whether or not this one was bound, so a later form is never
1990
+ // judged on an earlier tag's shapes.
1991
+ const propAttrs = pendingPropAttrs;
1992
+ const submitterProps = pendingSubmitterProps;
1993
+ const duplicateAction = pendingActionCount > 1;
1994
+ const submitterTag = pendingSubmitterTag;
1995
+ pendingPropAttrs = [];
1996
+ pendingSubmitterProps = [];
1997
+ pendingActionCount = 0;
1998
+ pendingSubmitterTag = null;
1999
+ if (pendingActionId != null) {
2000
+ assertConvergentBoundForm({ duplicateAction, propAttrs });
2001
+ const bound = bindFormActionStartTag(buf.slice(tagStart), pendingActionId);
2002
+ buf = buf.slice(0, tagStart) + bound.tag + bound.hidden;
2003
+ pendingActionId = null;
2004
+ }
2005
+ if (submitterTag != null) {
2006
+ // #1307, the SAME injection as the buffered machine, through the same
2007
+ // helper, so this second state machine cannot drift from it.
2008
+ buf = buf.slice(0, tagStart)
2009
+ + bindSubmitterStartTag(buf.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
2010
+ }
2011
+ };
2012
+ // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
2013
+ // here but applied for real in the browser, where all three are reflected IDL
2014
+ // attributes, so a bound form carrying one submits differently with JS than
2015
+ // without it. Recorded and refused at the `>`, once the tag's action hole is
2016
+ // known.
2017
+ const notePropAttr = (name, tag) => {
2018
+ const t = String(tag).toLowerCase();
2019
+ if (t === 'button' || t === 'input') {
2020
+ // #1207: the submitter twin. `name` / `value` / `formAction` / `formMethod`
2021
+ // / `formEnctype` all reflect on a submitter, so a `.prop` spelling is
2022
+ // dropped here and written to the attribute in the browser.
2023
+ if (isSubmitterReflectedProp(name)) pendingSubmitterProps.push(String(name));
2024
+ return;
2025
+ }
2026
+ if (t !== 'form') return;
2027
+ let n = String(name).toLowerCase();
2028
+ if (n === 'encoding') n = 'enctype';
2029
+ if (n === 'method' || n === 'enctype') pendingPropAttrs.push(String(name));
2030
+ };
2031
+ const noteActionHole = (name, tag) => {
2032
+ const t = String(tag).toLowerCase();
2033
+ const n = String(name).toLowerCase();
2034
+ if ((t === 'form' && n === 'action') ||
2035
+ ((t === 'button' || t === 'input') && n === 'formaction')) {
2036
+ pendingActionCount += 1;
2037
+ }
2038
+ };
2039
+
2040
+ // Same contract as the buffered machine, `allowRawtext` included: only the
2041
+ // `tag-name` and `in-tag` exits ever entered rawtext here either, so a start
2042
+ // tag ending on a bare or unquoted attribute must keep escaping its body.
2043
+ const handleTagEnd = (allowRawtext) => {
2044
+ closeBoundFormTag();
2045
+ isCloseTag = false;
2046
+ state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
2047
+ if (state === 'rawtext') rawTail = '';
2048
+ };
1793
2049
 
1794
2050
  for (let i = 0; i < strings.length; i++) {
1795
2051
  const s = strings[i];
@@ -1798,12 +2054,12 @@ async function streamTemplate(tr, ctx, controller) {
1798
2054
  switch (state) {
1799
2055
  case 'text':
1800
2056
  buf += c;
1801
- if (c === '<') state = 'tag-open';
2057
+ if (c === '<') { state = 'tag-open'; tagStart = buf.length - 1; isCloseTag = false; }
1802
2058
  break;
1803
2059
  case 'tag-open':
1804
2060
  buf += c;
1805
2061
  if (c === '!') state = 'bang-1';
1806
- else if (c === '/') { state = 'tag-name'; currentTag = ''; }
2062
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
1807
2063
  else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
1808
2064
  else state = 'text';
1809
2065
  break;
@@ -1825,16 +2081,14 @@ async function streamTemplate(tr, ctx, controller) {
1825
2081
  case 'tag-name':
1826
2082
  buf += c;
1827
2083
  if (c === '>') {
1828
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
1829
- if (state === 'rawtext') rawTail = '';
2084
+ handleTagEnd(true);
1830
2085
  } else if (/\s/.test(c)) state = 'in-tag';
1831
2086
  else currentTag += c.toLowerCase();
1832
2087
  break;
1833
2088
  case 'in-tag':
1834
2089
  buf += c;
1835
2090
  if (c === '>') {
1836
- state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
1837
- if (state === 'rawtext') rawTail = '';
2091
+ handleTagEnd(true);
1838
2092
  } else if (!/\s/.test(c) && c !== '/') {
1839
2093
  state = 'attr-name';
1840
2094
  attrName = c;
@@ -1853,18 +2107,18 @@ async function streamTemplate(tr, ctx, controller) {
1853
2107
  case 'attr-name':
1854
2108
  if (c === '=') { state = 'after-eq'; buf += c; }
1855
2109
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1856
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2110
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1857
2111
  else { attrName += c; buf += c; }
1858
2112
  break;
1859
2113
  case 'after-eq':
1860
2114
  if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
1861
2115
  else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1862
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2116
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1863
2117
  else { state = 'attr-unquoted'; buf += c; }
1864
2118
  break;
1865
2119
  case 'attr-unquoted':
1866
2120
  if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1867
- else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
2121
+ else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
1868
2122
  else buf += c;
1869
2123
  break;
1870
2124
  case 'attr-quoted':
@@ -1905,10 +2159,25 @@ async function streamTemplate(tr, ctx, controller) {
1905
2159
  // (and `.formAction` on a button or input) the client assignment
1906
2160
  // writes the source into the DOM, so refusing at SSR keeps a page
1907
2161
  // 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.
2162
+ // Elsewhere the property reflects nothing and a function stays
2163
+ // legal, which is why the check below is gated on a hyphen-free
2164
+ // tag. A custom element is excluded for a different reason than
2165
+ // "it does not reflect": a prop declared `reflect: true` DOES
2166
+ // reflect there, and used to write the source from its own setter.
2167
+ // #1169 guards that at the setter (a function removes the
2168
+ // attribute; an array carrying one does too, except under an
2169
+ // Object/Array type, where JSON drops it losslessly), so it
2170
+ // needs no commit-site check here.
2171
+ //
2172
+ // Unlike the buffered machine this drops EVERY prop, including
2173
+ // `<webjs-suspense .fallback>`: there is no injectDSD pre-pass on
2174
+ // this path (it is reached only through `renderToStream(v, { ssr:
2175
+ // false })`), so there is no consumer for a `data-webjs-fallback`
2176
+ // and emitting one would put an attribute in the markup that nothing
2177
+ // reads.
1910
2178
  if (kind === 'prop' && !currentTag.includes('-')) {
1911
2179
  assertNotFunctionReflectedActionProp(val, name, currentTag);
2180
+ notePropAttr(name, currentTag);
1912
2181
  }
1913
2182
  buf = buf.slice(0, attrStart);
1914
2183
  state = 'in-tag';
@@ -1923,7 +2192,28 @@ async function streamTemplate(tr, ctx, controller) {
1923
2192
  if (val) buf += `${name}=""`;
1924
2193
  state = 'in-tag';
1925
2194
  attrName = '';
2195
+ } else if (isBoundFormAction(val, attrName, currentTag)) {
2196
+ noteActionHole(attrName, currentTag);
2197
+ // The SAME bindings as the buffered renderer (#1155, #1207), in the
2198
+ // second machine, so `renderToStream(v, { ssr: false })` emits an
2199
+ // identical form rather than refusing one the page renderer accepts.
2200
+ if (currentTag === 'form') {
2201
+ pendingActionId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
2202
+ buf = buf.slice(0, attrStart).replace(/\s+$/, '');
2203
+ } else {
2204
+ assertSingleSubmitterAction(pendingSubmitterTag != null, currentTag);
2205
+ const attrs = parseStartTagAttrs(buf.slice(tagStart));
2206
+ if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
2207
+ if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
2208
+ if (attrs.has('form')) assertSubmitterHasNoFormAttribute(currentTag);
2209
+ const subId = assertIdentifiableAction(await resolveFormActionId(val), currentTag);
2210
+ pendingSubmitterTag = currentTag;
2211
+ buf = buf.slice(0, attrStart) + `name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"`;
2212
+ }
2213
+ state = 'in-tag';
2214
+ attrName = '';
1926
2215
  } else {
2216
+ noteActionHole(attrName, currentTag);
1927
2217
  // The SAME guard as the buffered renderer above. This is a second,
1928
2218
  // independent state machine, so it inherits nothing from that one;
1929
2219
  // a change to the rule has to land in both. Reached only via