@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.
- package/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +20 -20
- package/dist/webjs-core-testing.js.map +2 -2
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +4 -0
- package/index.d.ts +3 -0
- package/index.js +4 -0
- package/package.json +2 -2
- package/src/component.js +69 -0
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1127 -33
- package/src/optimistic.js +7 -2
- package/src/render-client.js +977 -108
- package/src/render-server.js +401 -62
- package/src/router-client.js +324 -21
- package/src/webjs-config.d.ts +49 -17
package/src/render-server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1909
|
-
//
|
|
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>` +
|