@webjsdev/core 0.7.47 → 0.7.49

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.
@@ -5,8 +5,8 @@ import {
5
5
  assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp,
6
6
  assertIdentifiableAction, bindFormActionStartTag, isBoundFormAction, resolveFormActionId,
7
7
  assertConvergentBoundForm, assertSubmitterHasNoName, assertSubmitterHasNoValue,
8
- assertSubmitterFormIsBound, assertSubmitterHasNoFormAttribute,
9
- assertSingleSubmitterAction, assertSubmitterStartTag, parseStartTagAttrs,
8
+ assertSubmitterHasNoFormAttribute,
9
+ assertSingleSubmitterAction, bindSubmitterStartTag, parseStartTagAttrs,
10
10
  isSubmitterReflectedProp, FORM_ACTION_FIELD,
11
11
  } from './form-action.js';
12
12
  import { lookup, lookupModuleUrl, allTags } from './registry.js';
@@ -31,19 +31,18 @@ import { cspNonce } from './csp-nonce.js';
31
31
  * Suspense still works but we fall back to emitting only the fallback
32
32
  * (the promise is dropped: appropriate for static pre-render).
33
33
  *
34
- * @typedef {{ pending: {id: string, promise: Promise<unknown>, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} SuspenseCtx
34
+ * @typedef {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} SuspenseCtx
35
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.
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.
44
43
  *
45
44
  * @param {unknown} value
46
- * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean, formScope?: 'none'|'unbound'|'bound'|'unknown' }} [opts]
45
+ * @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean }} [opts]
47
46
  * @returns {Promise<string>}
48
47
  */
49
48
  export async function renderToString(value, opts = { ssr: true }) {
@@ -54,23 +53,20 @@ export async function renderToString(value, opts = { ssr: true }) {
54
53
  // sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
55
54
  const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
56
55
  if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
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');
56
+ const html = await render(value, ctx);
60
57
  return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
61
58
  }
62
59
 
63
60
  /**
64
61
  * @param {unknown} value
65
62
  * @param {SuspenseCtx} [ctx]
66
- * @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
67
63
  * @returns {Promise<string>}
68
64
  */
69
- async function render(value, ctx, formScope = 'none') {
65
+ async function render(value, ctx) {
70
66
  if (value == null || value === false || value === true) return '';
71
67
  if (value && typeof /** @type any */ (value).then === 'function') {
72
68
  value = await value;
73
- return render(value, ctx, formScope);
69
+ return render(value, ctx);
74
70
  }
75
71
  // unsafeHTML: inject raw HTML string without escaping.
76
72
  if (isUnsafeHTML(value)) {
@@ -78,21 +74,21 @@ async function render(value, ctx, formScope = 'none') {
78
74
  }
79
75
  // live() on the server just unwraps and renders the inner value.
80
76
  if (isLive(value)) {
81
- return render(/** @type any */ (value).value, ctx, formScope);
77
+ return render(/** @type any */ (value).value, ctx);
82
78
  }
83
79
  // watch() on the server reads the signal once and inlines the
84
80
  // result. Subscription is a client-only concern; the SSR HTML
85
81
  // freezes a snapshot of the current value.
86
82
  if (isWatch(value)) {
87
- return render(/** @type any */ (value).signal.get(), ctx, formScope);
83
+ return render(/** @type any */ (value).signal.get(), ctx);
88
84
  }
89
85
  // keyed() on the server: render the wrapped template; key is client-only.
90
86
  if (isKeyed(value)) {
91
- return render(/** @type any */ (value).value, ctx, formScope);
87
+ return render(/** @type any */ (value).value, ctx);
92
88
  }
93
89
  // guard() on the server: always invoke the value function (no cache on SSR).
94
90
  if (isGuard(value)) {
95
- return render(/** @type any */ (value).fn(), ctx, formScope);
91
+ return render(/** @type any */ (value).fn(), ctx);
96
92
  }
97
93
  // templateContent() on the server: emit the template's innerHTML verbatim.
98
94
  if (isTemplateContent(value)) {
@@ -105,7 +101,7 @@ async function render(value, ctx, formScope = 'none') {
105
101
  }
106
102
  // cache() on the server: pass-through to the inner value.
107
103
  if (isCache(value)) {
108
- return render(/** @type any */ (value).value, ctx, formScope);
104
+ return render(/** @type any */ (value).value, ctx);
109
105
  }
110
106
  // until() on the server: render the first synchronous candidate, or
111
107
  // await the first Promise to settle when all candidates are Promises.
@@ -115,13 +111,13 @@ async function render(value, ctx, formScope = 'none') {
115
111
  const args = /** @type any */ (value).args;
116
112
  for (const a of args) {
117
113
  if (!a || typeof (/** @type any */ (a).then) !== 'function') {
118
- return render(a, ctx, formScope);
114
+ return render(a, ctx);
119
115
  }
120
116
  }
121
117
  if (args.length > 0) {
122
118
  try {
123
119
  const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
124
- return render(winner, ctx, formScope);
120
+ return render(winner, ctx);
125
121
  } catch {
126
122
  return '';
127
123
  }
@@ -134,35 +130,34 @@ async function render(value, ctx, formScope = 'none') {
134
130
  return '';
135
131
  }
136
132
  if (Array.isArray(value)) {
137
- const parts = await Promise.all(value.map((v) => render(v, ctx, formScope)));
133
+ const parts = await Promise.all(value.map((v) => render(v, ctx)));
138
134
  return parts.join('');
139
135
  }
140
136
  if (isRepeat(value)) {
141
137
  const r = /** @type any */ (value);
142
- const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx, formScope)));
138
+ const parts = await Promise.all(r.items.map((it, i) => render(r.templateFn(it, i), ctx)));
143
139
  return parts.join('');
144
140
  }
145
141
  if (isSuspense(value)) {
146
142
  const s = /** @type any */ (value);
147
- const fallback = await render(s.fallback, ctx, formScope);
143
+ const fallback = await render(s.fallback, ctx);
148
144
  if (ctx) {
149
145
  const id = `s${ctx.nextId++}`;
150
- ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
146
+ ctx.pending.push({ id, promise: Promise.resolve(s.children) });
151
147
  return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
152
148
  }
153
149
  return fallback;
154
150
  }
155
- if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx, formScope);
151
+ if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
156
152
  return escapeText(String(value));
157
153
  }
158
154
 
159
155
  /**
160
156
  * @param {import('./html.js').TemplateResult} tr
161
157
  * @param {SuspenseCtx} [ctx]
162
- * @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
163
158
  * @returns {Promise<string>}
164
159
  */
165
- async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
160
+ async function renderTemplate(tr, ctx) {
166
161
  const { strings, values } = tr;
167
162
  let out = '';
168
163
  let state = 'text';
@@ -202,9 +197,6 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
202
197
  // and for the same reason. A top-level scan that simply contains no form
203
198
  // stays 'none' and is still refused, because there the answer IS known.
204
199
  //
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
200
  let isCloseTag = false;
209
201
 
210
202
  // A bound `action=${fn}` is committed at its hole, but the edits it implies
@@ -229,21 +221,15 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
229
221
  const bound = bindFormActionStartTag(out.slice(tagStart), pendingActionId);
230
222
  out = out.slice(0, tagStart) + bound.tag + bound.hidden;
231
223
  pendingActionId = null;
232
- formScope = 'bound';
233
224
  }
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
225
  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 });
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 });
247
233
  }
248
234
  };
249
235
  // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
@@ -275,8 +261,7 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
275
261
  };
276
262
 
277
263
  // 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.
264
+ // stays in one place rather than at five call sites.
280
265
  //
281
266
  // `allowRawtext` is NOT a preference. Only two of those five call sites ever
282
267
  // entered rawtext: the `tag-name` and `in-tag` exits. The three attribute
@@ -289,14 +274,6 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
289
274
  // question from #1207; this preserves it exactly.
290
275
  const handleTagEnd = (allowRawtext) => {
291
276
  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
277
  isCloseTag = false;
301
278
  state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
302
279
  if (state === 'rawtext') rawTail = '';
@@ -401,7 +378,7 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
401
378
  out += String(val ?? '');
402
379
  rawTail = '';
403
380
  } else if (state === 'text') {
404
- out += await render(val, ctx, formScope);
381
+ out += await render(val, ctx);
405
382
  } else if (state === 'after-eq') {
406
383
  const prefix = attrName[0];
407
384
  const name = attrName.slice(1);
@@ -423,14 +400,17 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
423
400
  // page-level `.prop` on a native element could have set the
424
401
  // property to begin with.
425
402
  out = out.slice(0, attrStart);
426
- // `<webjs-suspense .fallback=${html`...`}>` (#471). A TemplateResult
427
- // is not serializer-safe (the normal data-webjs-prop-* path would
428
- // drop it) and a normal custom-element prop applies only at
429
- // hydration, too late for the streaming placeholder. So render the
430
- // fallback to HTML now and carry it as data-webjs-fallback, which the
431
- // injectDSD streaming pre-pass reads as the boundary placeholder.
403
+ // `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
404
+ // defined only in the browser, so the injectDSD walk skips it
405
+ // (`lookup(tag)` finds no class) and no server-side instance runs
406
+ // consumePropAttrs. A normal data-webjs-prop-* binding would then
407
+ // land at connectedCallback, too late for the streaming placeholder.
408
+ // So render the fallback to HTML now and carry it as
409
+ // data-webjs-fallback, which the injectDSD streaming pre-pass reads
410
+ // as the boundary placeholder. (The value itself would serialize
411
+ // fine: a TemplateResult is a plain {strings, values} object.)
432
412
  if (currentTag === 'webjs-suspense' && name === 'fallback') {
433
- const fbHtml = await render(val, ctx, formScope);
413
+ const fbHtml = await render(val, ctx);
434
414
  out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
435
415
  state = 'in-tag';
436
416
  attrName = '';
@@ -516,11 +496,6 @@ async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
516
496
  // author gets the duplicate message rather than a confusing
517
497
  // complaint about the `name` the FIRST hole just injected.
518
498
  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
499
  const attrs = parseStartTagAttrs(out.slice(tagStart));
525
500
  if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
526
501
  if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
@@ -1755,9 +1730,37 @@ function applyAttrsToInstance(instance, attrs, Cls) {
1755
1730
  // `raw` is the entity-encoded attribute text (parseAttrs returns the
1756
1731
  // literal characters between the quotes), so decode the HTML entities
1757
1732
  // before JSON.parse. A JSON attribute carries `&quot;` for every `"`;
1758
- // parsing it raw throws and would silently fall back to the string,
1759
- // leaving an Object/Array prop holding a string at SSR.
1760
- try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = raw; }
1733
+ // parsing it raw throws, and the prop then falls to the failure value
1734
+ // below rather than to the object the author wrote.
1735
+ //
1736
+ // An unparseable attribute falls back to `null`, NOT to the raw string
1737
+ // (#1253), matching this branch's counterpart in
1738
+ // `attributeChangedCallback`. They have to agree, or the same
1739
+ // `<my-el cfg="oops">` SSRs holding a string and re-renders holding
1740
+ // something else the moment the element upgrades. `null` is the right
1741
+ // value to agree on: a string is never a valid value for a property the
1742
+ // author declared `Object` or `Array`. This function iterates the
1743
+ // attributes PRESENT on the source tag, so an absent one is not read here
1744
+ // at all and its property keeps whatever the constructor gave it; the
1745
+ // agreement being asserted is about a present, unparseable attribute.
1746
+ //
1747
+ // That agreement is about this FALLBACK, not a guarantee that the two
1748
+ // readers see the same attributes in the first place. This function
1749
+ // walks the parsed source tag while the client goes through
1750
+ // `observedAttributes` and the browser's own name-lowercasing, and
1751
+ // hand-written markup can land in the gaps between those routes (the
1752
+ // entity decoding just below is one, since it reverses far less than a
1753
+ // browser does). Those gaps predate #1253 and it neither causes nor
1754
+ // closes any of them; no attempt is made here to enumerate them,
1755
+ // because every attempt so far has been incomplete.
1756
+ //
1757
+ // Scoped to the DEFAULT converter, which is all this branch is. The
1758
+ // client reader tries `converter.fromAttribute` FIRST and only falls
1759
+ // through to here when there is none, while this function has no
1760
+ // converter arm at all, so a prop declaring one is read differently by
1761
+ // the two sides regardless of what this line does. That gap predates
1762
+ // #1253 and is left alone rather than widened.
1763
+ try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = null; }
1761
1764
  } else instance[propName] = raw;
1762
1765
  }
1763
1766
  }
@@ -1889,29 +1892,28 @@ export function renderToStream(value, opts = { ssr: true }) {
1889
1892
  * @param {unknown} value
1890
1893
  * @param {SuspenseCtx} [ctx]
1891
1894
  * @param {ReadableStreamDefaultController<string>} controller
1892
- * @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
1893
1895
  */
1894
- async function streamRender(value, ctx, controller, formScope = 'none') {
1896
+ async function streamRender(value, ctx, controller) {
1895
1897
  if (value == null || value === false || value === true) return;
1896
1898
  if (value && typeof /** @type any */ (value).then === 'function') {
1897
1899
  value = await value;
1898
- return streamRender(value, ctx, controller, formScope);
1900
+ return streamRender(value, ctx, controller);
1899
1901
  }
1900
1902
  if (isUnsafeHTML(value)) {
1901
1903
  controller.enqueue(String(/** @type any */ (value).value ?? ''));
1902
1904
  return;
1903
1905
  }
1904
1906
  if (isLive(value)) {
1905
- return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1907
+ return streamRender(/** @type any */ (value).value, ctx, controller);
1906
1908
  }
1907
1909
  if (isWatch(value)) {
1908
- return streamRender(/** @type any */ (value).signal.get(), ctx, controller, formScope);
1910
+ return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
1909
1911
  }
1910
1912
  if (isKeyed(value)) {
1911
- return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1913
+ return streamRender(/** @type any */ (value).value, ctx, controller);
1912
1914
  }
1913
1915
  if (isGuard(value)) {
1914
- return streamRender(/** @type any */ (value).fn(), ctx, controller, formScope);
1916
+ return streamRender(/** @type any */ (value).fn(), ctx, controller);
1915
1917
  }
1916
1918
  if (isTemplateContent(value)) {
1917
1919
  const tpl = /** @type any */ (value).template;
@@ -1922,19 +1924,19 @@ async function streamRender(value, ctx, controller, formScope = 'none') {
1922
1924
  return;
1923
1925
  }
1924
1926
  if (isCache(value)) {
1925
- return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
1927
+ return streamRender(/** @type any */ (value).value, ctx, controller);
1926
1928
  }
1927
1929
  if (isUntil(value)) {
1928
1930
  const args = /** @type any */ (value).args;
1929
1931
  for (const a of args) {
1930
1932
  if (!a || typeof (/** @type any */ (a).then) !== 'function') {
1931
- return streamRender(a, ctx, controller, formScope);
1933
+ return streamRender(a, ctx, controller);
1932
1934
  }
1933
1935
  }
1934
1936
  if (args.length > 0) {
1935
1937
  try {
1936
1938
  const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
1937
- return streamRender(winner, ctx, controller, formScope);
1939
+ return streamRender(winner, ctx, controller);
1938
1940
  } catch {
1939
1941
  return;
1940
1942
  }
@@ -1945,13 +1947,13 @@ async function streamRender(value, ctx, controller, formScope = 'none') {
1945
1947
  return;
1946
1948
  }
1947
1949
  if (Array.isArray(value)) {
1948
- for (const v of value) await streamRender(v, ctx, controller, formScope);
1950
+ for (const v of value) await streamRender(v, ctx, controller);
1949
1951
  return;
1950
1952
  }
1951
1953
  if (isRepeat(value)) {
1952
1954
  const r = /** @type any */ (value);
1953
1955
  for (let i = 0; i < r.items.length; i++) {
1954
- await streamRender(r.templateFn(r.items[i], i), ctx, controller, formScope);
1956
+ await streamRender(r.templateFn(r.items[i], i), ctx, controller);
1955
1957
  }
1956
1958
  return;
1957
1959
  }
@@ -1960,16 +1962,16 @@ async function streamRender(value, ctx, controller, formScope = 'none') {
1960
1962
  if (ctx) {
1961
1963
  const id = `s${ctx.nextId++}`;
1962
1964
  controller.enqueue(`<webjs-boundary id="${id}">`);
1963
- await streamRender(s.fallback, ctx, controller, formScope);
1965
+ await streamRender(s.fallback, ctx, controller);
1964
1966
  controller.enqueue(`</webjs-boundary>`);
1965
- ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
1967
+ ctx.pending.push({ id, promise: Promise.resolve(s.children) });
1966
1968
  } else {
1967
- await streamRender(s.fallback, ctx, controller, formScope);
1969
+ await streamRender(s.fallback, ctx, controller);
1968
1970
  }
1969
1971
  return;
1970
1972
  }
1971
1973
  if (isTemplate(value)) {
1972
- await streamTemplate(/** @type any */ (value), ctx, controller, formScope);
1974
+ await streamTemplate(/** @type any */ (value), ctx, controller);
1973
1975
  return;
1974
1976
  }
1975
1977
  controller.enqueue(escapeText(String(value)));
@@ -1982,9 +1984,8 @@ async function streamRender(value, ctx, controller, formScope = 'none') {
1982
1984
  * @param {import('./html.js').TemplateResult} tr
1983
1985
  * @param {SuspenseCtx} [ctx]
1984
1986
  * @param {ReadableStreamDefaultController<string>} controller
1985
- * @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
1986
1987
  */
1987
- async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
1988
+ async function streamTemplate(tr, ctx, controller) {
1988
1989
  const { strings, values } = tr;
1989
1990
  let state = 'text';
1990
1991
  let attrName = '';
@@ -2010,7 +2011,6 @@ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
2010
2011
  let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
2011
2012
  // See the buffered machine: seeded from the caller so a nested template
2012
2013
  // rendered into a bound form's children knows it is inside one.
2013
- let formScope = formScopeAtStart;
2014
2014
  let isCloseTag = false;
2015
2015
 
2016
2016
  // See the buffered machine for why this runs at the `>` rather than at the
@@ -2032,17 +2032,12 @@ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
2032
2032
  const bound = bindFormActionStartTag(buf.slice(tagStart), pendingActionId);
2033
2033
  buf = buf.slice(0, tagStart) + bound.tag + bound.hidden;
2034
2034
  pendingActionId = null;
2035
- formScope = 'bound';
2036
2035
  }
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
2036
  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 });
2037
+ // #1307, the SAME injection as the buffered machine, through the same
2038
+ // helper, so this second state machine cannot drift from it.
2039
+ buf = buf.slice(0, tagStart)
2040
+ + bindSubmitterStartTag(buf.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
2046
2041
  }
2047
2042
  };
2048
2043
  // #1155: a `.method` / `.enctype` / `.encoding` prop on a form is dropped
@@ -2078,14 +2073,6 @@ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
2078
2073
  // tag ending on a bare or unquoted attribute must keep escaping its body.
2079
2074
  const handleTagEnd = (allowRawtext) => {
2080
2075
  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
2076
  isCloseTag = false;
2090
2077
  state = allowRawtext && isRawtextTag(currentTag) ? 'rawtext' : 'text';
2091
2078
  if (state === 'rawtext') rawTail = '';
@@ -2189,7 +2176,7 @@ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
2189
2176
  } else if (state === 'text') {
2190
2177
  // Flush the buffered static content before streaming the value.
2191
2178
  if (buf) { controller.enqueue(buf); buf = ''; }
2192
- await streamRender(val, ctx, controller, formScope);
2179
+ await streamRender(val, ctx, controller);
2193
2180
  } else if (state === 'after-eq') {
2194
2181
  const prefix = attrName[0];
2195
2182
  const name = attrName.slice(1);
@@ -2246,11 +2233,6 @@ async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
2246
2233
  buf = buf.slice(0, attrStart).replace(/\s+$/, '');
2247
2234
  } else {
2248
2235
  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
2236
  const attrs = parseStartTagAttrs(buf.slice(tagStart));
2255
2237
  if (attrs.has('name')) assertSubmitterHasNoName(attrs.get('name') || '', currentTag, false);
2256
2238
  if (attrs.has('value')) assertSubmitterHasNoValue(currentTag);
@@ -2306,10 +2288,10 @@ async function streamSuspenseBoundaries(ctx, controller, dev) {
2306
2288
  while (ctx.pending.length) {
2307
2289
  const batch = ctx.pending.splice(0);
2308
2290
  await Promise.all(
2309
- batch.map(async ({ id, promise, formScope }) => {
2291
+ batch.map(async ({ id, promise }) => {
2310
2292
  try {
2311
2293
  const resolved = await promise;
2312
- const html = await render(resolved, ctx, formScope);
2294
+ const html = await render(resolved, ctx);
2313
2295
  const full = await injectDSD(html, ctx, [], dev);
2314
2296
  controller.enqueue(
2315
2297
  `<template data-webjs-resolve="${id}">${full}</template>` +