@webjsdev/core 0.7.45 → 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 +22 -21
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +7 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -0
- package/index.d.ts +4 -0
- package/index.js +5 -0
- package/package.json +2 -2
- package/src/asset-url.d.ts +3 -0
- package/src/asset-url.js +99 -0
- package/src/component.js +137 -2
- package/src/form-action.d.ts +16 -0
- package/src/form-action.js +1257 -0
- package/src/optimistic.js +7 -2
- package/src/render-client.js +1004 -107
- package/src/render-server.js +847 -70
- package/src/router-client.js +338 -23
- package/src/webjs-config.d.ts +49 -17
package/src/render-server.js
CHANGED
|
@@ -1,6 +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 {
|
|
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';
|
|
4
12
|
import { lookup, lookupModuleUrl, allTags } from './registry.js';
|
|
5
13
|
import { stylesToString, isCSS } from './css.js';
|
|
6
14
|
import { isRepeat } from './repeat.js';
|
|
@@ -23,10 +31,19 @@ import { cspNonce } from './csp-nonce.js';
|
|
|
23
31
|
* Suspense still works but we fall back to emitting only the fallback
|
|
24
32
|
* (the promise is dropped: appropriate for static pre-render).
|
|
25
33
|
*
|
|
26
|
-
* @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.
|
|
27
44
|
*
|
|
28
45
|
* @param {unknown} value
|
|
29
|
-
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx }} [opts]
|
|
46
|
+
* @param {{ ssr?: boolean, suspenseCtx?: SuspenseCtx, dev?: boolean, formScope?: 'none'|'unbound'|'bound'|'unknown' }} [opts]
|
|
30
47
|
* @returns {Promise<string>}
|
|
31
48
|
*/
|
|
32
49
|
export async function renderToString(value, opts = { ssr: true }) {
|
|
@@ -37,20 +54,23 @@ export async function renderToString(value, opts = { ssr: true }) {
|
|
|
37
54
|
// sharing it see the same flag. Undefined stays undefined (NODE_ENV fallback).
|
|
38
55
|
const dev = opts && opts.dev !== undefined ? opts.dev : ctx && ctx.dev;
|
|
39
56
|
if (ctx && ctx.dev === undefined && dev !== undefined) ctx.dev = dev;
|
|
40
|
-
|
|
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');
|
|
41
60
|
return opts && opts.ssr === false ? html : await injectDSD(html, ctx, [], dev);
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
/**
|
|
45
64
|
* @param {unknown} value
|
|
46
65
|
* @param {SuspenseCtx} [ctx]
|
|
66
|
+
* @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
|
|
47
67
|
* @returns {Promise<string>}
|
|
48
68
|
*/
|
|
49
|
-
async function render(value, ctx) {
|
|
69
|
+
async function render(value, ctx, formScope = 'none') {
|
|
50
70
|
if (value == null || value === false || value === true) return '';
|
|
51
71
|
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
52
72
|
value = await value;
|
|
53
|
-
return render(value, ctx);
|
|
73
|
+
return render(value, ctx, formScope);
|
|
54
74
|
}
|
|
55
75
|
// unsafeHTML: inject raw HTML string without escaping.
|
|
56
76
|
if (isUnsafeHTML(value)) {
|
|
@@ -58,21 +78,21 @@ async function render(value, ctx) {
|
|
|
58
78
|
}
|
|
59
79
|
// live() on the server just unwraps and renders the inner value.
|
|
60
80
|
if (isLive(value)) {
|
|
61
|
-
return render(/** @type any */ (value).value, ctx);
|
|
81
|
+
return render(/** @type any */ (value).value, ctx, formScope);
|
|
62
82
|
}
|
|
63
83
|
// watch() on the server reads the signal once and inlines the
|
|
64
84
|
// result. Subscription is a client-only concern; the SSR HTML
|
|
65
85
|
// freezes a snapshot of the current value.
|
|
66
86
|
if (isWatch(value)) {
|
|
67
|
-
return render(/** @type any */ (value).signal.get(), ctx);
|
|
87
|
+
return render(/** @type any */ (value).signal.get(), ctx, formScope);
|
|
68
88
|
}
|
|
69
89
|
// keyed() on the server: render the wrapped template; key is client-only.
|
|
70
90
|
if (isKeyed(value)) {
|
|
71
|
-
return render(/** @type any */ (value).value, ctx);
|
|
91
|
+
return render(/** @type any */ (value).value, ctx, formScope);
|
|
72
92
|
}
|
|
73
93
|
// guard() on the server: always invoke the value function (no cache on SSR).
|
|
74
94
|
if (isGuard(value)) {
|
|
75
|
-
return render(/** @type any */ (value).fn(), ctx);
|
|
95
|
+
return render(/** @type any */ (value).fn(), ctx, formScope);
|
|
76
96
|
}
|
|
77
97
|
// templateContent() on the server: emit the template's innerHTML verbatim.
|
|
78
98
|
if (isTemplateContent(value)) {
|
|
@@ -85,7 +105,7 @@ async function render(value, ctx) {
|
|
|
85
105
|
}
|
|
86
106
|
// cache() on the server: pass-through to the inner value.
|
|
87
107
|
if (isCache(value)) {
|
|
88
|
-
return render(/** @type any */ (value).value, ctx);
|
|
108
|
+
return render(/** @type any */ (value).value, ctx, formScope);
|
|
89
109
|
}
|
|
90
110
|
// until() on the server: render the first synchronous candidate, or
|
|
91
111
|
// await the first Promise to settle when all candidates are Promises.
|
|
@@ -95,13 +115,13 @@ async function render(value, ctx) {
|
|
|
95
115
|
const args = /** @type any */ (value).args;
|
|
96
116
|
for (const a of args) {
|
|
97
117
|
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
98
|
-
return render(a, ctx);
|
|
118
|
+
return render(a, ctx, formScope);
|
|
99
119
|
}
|
|
100
120
|
}
|
|
101
121
|
if (args.length > 0) {
|
|
102
122
|
try {
|
|
103
123
|
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
104
|
-
return render(winner, ctx);
|
|
124
|
+
return render(winner, ctx, formScope);
|
|
105
125
|
} catch {
|
|
106
126
|
return '';
|
|
107
127
|
}
|
|
@@ -114,34 +134,35 @@ async function render(value, ctx) {
|
|
|
114
134
|
return '';
|
|
115
135
|
}
|
|
116
136
|
if (Array.isArray(value)) {
|
|
117
|
-
const parts = await Promise.all(value.map((v) => render(v, ctx)));
|
|
137
|
+
const parts = await Promise.all(value.map((v) => render(v, ctx, formScope)));
|
|
118
138
|
return parts.join('');
|
|
119
139
|
}
|
|
120
140
|
if (isRepeat(value)) {
|
|
121
141
|
const r = /** @type any */ (value);
|
|
122
|
-
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)));
|
|
123
143
|
return parts.join('');
|
|
124
144
|
}
|
|
125
145
|
if (isSuspense(value)) {
|
|
126
146
|
const s = /** @type any */ (value);
|
|
127
|
-
const fallback = await render(s.fallback, ctx);
|
|
147
|
+
const fallback = await render(s.fallback, ctx, formScope);
|
|
128
148
|
if (ctx) {
|
|
129
149
|
const id = `s${ctx.nextId++}`;
|
|
130
|
-
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
150
|
+
ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
|
|
131
151
|
return `<webjs-boundary id="${id}">${fallback}</webjs-boundary>`;
|
|
132
152
|
}
|
|
133
153
|
return fallback;
|
|
134
154
|
}
|
|
135
|
-
if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx);
|
|
155
|
+
if (isTemplate(value)) return renderTemplate(/** @type any */ (value), ctx, formScope);
|
|
136
156
|
return escapeText(String(value));
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
/**
|
|
140
160
|
* @param {import('./html.js').TemplateResult} tr
|
|
141
161
|
* @param {SuspenseCtx} [ctx]
|
|
162
|
+
* @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
|
|
142
163
|
* @returns {Promise<string>}
|
|
143
164
|
*/
|
|
144
|
-
async function renderTemplate(tr, ctx) {
|
|
165
|
+
async function renderTemplate(tr, ctx, formScopeAtStart = 'none') {
|
|
145
166
|
const { strings, values } = tr;
|
|
146
167
|
let out = '';
|
|
147
168
|
let state = 'text';
|
|
@@ -151,6 +172,135 @@ async function renderTemplate(tr, ctx) {
|
|
|
151
172
|
let commentDashes = 0;
|
|
152
173
|
let currentTag = ''; // lowercased tag name currently being parsed
|
|
153
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
|
+
};
|
|
154
304
|
|
|
155
305
|
for (let i = 0; i < strings.length; i++) {
|
|
156
306
|
const s = strings[i];
|
|
@@ -159,12 +309,12 @@ async function renderTemplate(tr, ctx) {
|
|
|
159
309
|
switch (state) {
|
|
160
310
|
case 'text':
|
|
161
311
|
out += c;
|
|
162
|
-
if (c === '<') state = 'tag-open';
|
|
312
|
+
if (c === '<') { state = 'tag-open'; tagStart = out.length - 1; isCloseTag = false; }
|
|
163
313
|
break;
|
|
164
314
|
case 'tag-open':
|
|
165
315
|
out += c;
|
|
166
316
|
if (c === '!') state = 'bang-1';
|
|
167
|
-
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
317
|
+
else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
|
|
168
318
|
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
169
319
|
else state = 'text';
|
|
170
320
|
break;
|
|
@@ -186,16 +336,14 @@ async function renderTemplate(tr, ctx) {
|
|
|
186
336
|
case 'tag-name':
|
|
187
337
|
out += c;
|
|
188
338
|
if (c === '>') {
|
|
189
|
-
|
|
190
|
-
if (state === 'rawtext') rawTail = '';
|
|
339
|
+
handleTagEnd(true);
|
|
191
340
|
} else if (/\s/.test(c)) state = 'in-tag';
|
|
192
341
|
else currentTag += c.toLowerCase();
|
|
193
342
|
break;
|
|
194
343
|
case 'in-tag':
|
|
195
344
|
out += c;
|
|
196
345
|
if (c === '>') {
|
|
197
|
-
|
|
198
|
-
if (state === 'rawtext') rawTail = '';
|
|
346
|
+
handleTagEnd(true);
|
|
199
347
|
} else if (!/\s/.test(c) && c !== '/') {
|
|
200
348
|
state = 'attr-name';
|
|
201
349
|
attrName = c;
|
|
@@ -214,18 +362,18 @@ async function renderTemplate(tr, ctx) {
|
|
|
214
362
|
case 'attr-name':
|
|
215
363
|
if (c === '=') { state = 'after-eq'; out += c; }
|
|
216
364
|
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
217
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
365
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
218
366
|
else { attrName += c; out += c; }
|
|
219
367
|
break;
|
|
220
368
|
case 'after-eq':
|
|
221
369
|
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
|
|
222
370
|
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
223
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
371
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
224
372
|
else { state = 'attr-unquoted'; out += c; }
|
|
225
373
|
break;
|
|
226
374
|
case 'attr-unquoted':
|
|
227
375
|
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
|
|
228
|
-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
|
|
376
|
+
else if (c === '>') { state = 'text'; attrName = ''; out += c; handleTagEnd(false); }
|
|
229
377
|
else out += c;
|
|
230
378
|
break;
|
|
231
379
|
case 'attr-quoted':
|
|
@@ -253,7 +401,7 @@ async function renderTemplate(tr, ctx) {
|
|
|
253
401
|
out += String(val ?? '');
|
|
254
402
|
rawTail = '';
|
|
255
403
|
} else if (state === 'text') {
|
|
256
|
-
out += await render(val, ctx);
|
|
404
|
+
out += await render(val, ctx, formScope);
|
|
257
405
|
} else if (state === 'after-eq') {
|
|
258
406
|
const prefix = attrName[0];
|
|
259
407
|
const name = attrName.slice(1);
|
|
@@ -282,13 +430,25 @@ async function renderTemplate(tr, ctx) {
|
|
|
282
430
|
// fallback to HTML now and carry it as data-webjs-fallback, which the
|
|
283
431
|
// injectDSD streaming pre-pass reads as the boundary placeholder.
|
|
284
432
|
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
285
|
-
const fbHtml = await render(val, ctx);
|
|
433
|
+
const fbHtml = await render(val, ctx, formScope);
|
|
286
434
|
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
287
435
|
state = 'in-tag';
|
|
288
436
|
attrName = '';
|
|
289
437
|
continue;
|
|
290
438
|
}
|
|
291
439
|
if (!currentTag.includes('-')) {
|
|
440
|
+
// A native element's `.prop` is dropped at SSR, so this path never
|
|
441
|
+
// leaked here. It still refuses a function where the property is a
|
|
442
|
+
// REFLECTED IDL attribute (`.action` on a form, `.formAction` on a
|
|
443
|
+
// button or input), so the rule does not depend on which renderer
|
|
444
|
+
// sees it first: the client sets that property for real and the
|
|
445
|
+
// reflection writes the source into the DOM. A page that renders
|
|
446
|
+
// clean on the server and throws on hydration is a worse failure
|
|
447
|
+
// than one that refuses at the earliest point. Elsewhere the
|
|
448
|
+
// property is a plain expando that reflects nothing, so refusing
|
|
449
|
+
// it would be a false positive.
|
|
450
|
+
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
451
|
+
notePropAttr(name, currentTag);
|
|
292
452
|
state = 'in-tag';
|
|
293
453
|
attrName = '';
|
|
294
454
|
continue;
|
|
@@ -318,16 +478,74 @@ async function renderTemplate(tr, ctx) {
|
|
|
318
478
|
state = 'in-tag';
|
|
319
479
|
attrName = '';
|
|
320
480
|
} else if (kind === 'bool') {
|
|
481
|
+
// Never leaked (a boolean binding stringifies nothing), but
|
|
482
|
+
// `?action=${fn}` is meaningless in every case and refusing it keeps
|
|
483
|
+
// the rule true for every sigil rather than only the quoted ones.
|
|
484
|
+
assertNotFunctionActionAttr(val, name, currentTag);
|
|
321
485
|
out = out.slice(0, attrStart);
|
|
322
486
|
if (val) out += `${name}=""`;
|
|
323
487
|
state = 'in-tag';
|
|
324
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 = '';
|
|
325
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);
|
|
538
|
+
// #1154: never stringify a function into action=/formaction= (it
|
|
539
|
+
// would serialize a server action's source into the served HTML).
|
|
540
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
326
541
|
out += `"${escapeAttr(String(val ?? ''))}"`;
|
|
327
542
|
state = 'in-tag';
|
|
328
543
|
attrName = '';
|
|
329
544
|
}
|
|
330
545
|
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
546
|
+
// Same guard for a hole inside a quoted/unquoted value, the
|
|
547
|
+
// `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
|
|
548
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
331
549
|
out += escapeAttr(String(val ?? ''));
|
|
332
550
|
}
|
|
333
551
|
}
|
|
@@ -411,6 +629,294 @@ function defaultSSRErrorTemplate(tag, err, dev) {
|
|
|
411
629
|
</div>`;
|
|
412
630
|
}
|
|
413
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Index just past the end of the comment starting at `start`, or -1 if it is
|
|
634
|
+
* unterminated. Shared so every scanner that has to decide where a comment
|
|
635
|
+
* stops agrees, including on the spec short forms.
|
|
636
|
+
*
|
|
637
|
+
* @param {string} html
|
|
638
|
+
* @param {number} start index of the `<` of `<!--`
|
|
639
|
+
* @returns {number}
|
|
640
|
+
*/
|
|
641
|
+
function endOfComment(html, start) {
|
|
642
|
+
let p = start + 4;
|
|
643
|
+
// `<!-->` and `<!--->` are comments whose data is empty (spec short forms).
|
|
644
|
+
if (html[p] === '>') return p + 1;
|
|
645
|
+
if (html.startsWith('->', p)) return p + 2;
|
|
646
|
+
while (p < html.length) {
|
|
647
|
+
// `--!>` is the spec's "abrupt closing" form and closes just like `-->`.
|
|
648
|
+
if (html.startsWith('-->', p)) return p + 3;
|
|
649
|
+
if (html.startsWith('--!>', p)) return p + 4;
|
|
650
|
+
p += 1;
|
|
651
|
+
}
|
|
652
|
+
return -1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Index of the `</script` that really closes a `<script>` whose content starts
|
|
657
|
+
* at `from`, or -1 when unterminated (#1134).
|
|
658
|
+
*
|
|
659
|
+
* Script data is not plain raw text: once the content contains `<!--` followed
|
|
660
|
+
* by `<script`, the tokenizer is in the script-data-double-escaped state, where
|
|
661
|
+
* a `</script>` is TEXT (it only steps back to the escaped state) and the
|
|
662
|
+
* element ends at the NEXT `</script>`. The legacy comment-wrapped inline
|
|
663
|
+
* script that document.writes a script tag is the pattern that produces this.
|
|
664
|
+
* Stopping at the first `</script>` there re-opened the original #1128 bug in
|
|
665
|
+
* the one element the scanner most explicitly claims to handle.
|
|
666
|
+
*
|
|
667
|
+
* @param {string} html
|
|
668
|
+
* @param {number} from index just past the opening tag's `>`
|
|
669
|
+
* @returns {number}
|
|
670
|
+
*/
|
|
671
|
+
function endOfScriptContent(html, from) {
|
|
672
|
+
const re = /<!--|-->|<\/script(?=[\s/>])|<script(?=[\s/>])/gi;
|
|
673
|
+
re.lastIndex = from;
|
|
674
|
+
let escaped = false;
|
|
675
|
+
let dbl = false;
|
|
676
|
+
let m;
|
|
677
|
+
while ((m = re.exec(html)) !== null) {
|
|
678
|
+
const t = m[0];
|
|
679
|
+
if (t === '<!--') {
|
|
680
|
+
// The token's own trailing `--` puts the tokenizer in a dash-dash state
|
|
681
|
+
// REGARDLESS of what state it was in (`<!` are inert bytes in escaped and
|
|
682
|
+
// double-escaped states too), and every dash-dash state exits straight
|
|
683
|
+
// back to plain script data on `>`. So `<!-->`, `<!--->`, and any dash
|
|
684
|
+
// run followed by `>` clear BOTH flags: entering fresh it cancels the
|
|
685
|
+
// escape before it starts, and inside an escaped or double-escaped body
|
|
686
|
+
// it is the exit a browser honours, after which the element ends at the
|
|
687
|
+
// next `</script>`.
|
|
688
|
+
let q = m.index + 4;
|
|
689
|
+
while (html[q] === '-') q += 1;
|
|
690
|
+
if (html[q] === '>') { escaped = false; dbl = false; re.lastIndex = q + 1; }
|
|
691
|
+
else if (!escaped) escaped = true;
|
|
692
|
+
}
|
|
693
|
+
else if (t === '-->') { escaped = false; dbl = false; }
|
|
694
|
+
else if (t[1] === '/') {
|
|
695
|
+
if (dbl) dbl = false;
|
|
696
|
+
else return m.index;
|
|
697
|
+
} else if (escaped) dbl = true;
|
|
698
|
+
}
|
|
699
|
+
return -1;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Byte ranges of `html` where a tag-shaped match is NOT an element (#1128).
|
|
704
|
+
*
|
|
705
|
+
* The element scanners below match tags with a flat regex over already-
|
|
706
|
+
* assembled markup, which has no notion of an HTML context. So a registered tag
|
|
707
|
+
* name written inside a comment used to be constructed and rendered as a real
|
|
708
|
+
* element, and the replacement consumed the rest of the comment INCLUDING its
|
|
709
|
+
* closing `-->`, leaving an unterminated comment that swallowed every following
|
|
710
|
+
* byte. Whether it happened depended on whether the name in the comment was a
|
|
711
|
+
* registered component, which is what made it look random.
|
|
712
|
+
*
|
|
713
|
+
* This is a single left-to-right pass rather than a search for `<!--`, because
|
|
714
|
+
* the naive version introduces failures worse than the bug: an `<!--` inside an
|
|
715
|
+
* attribute value (`title="use <!-- here"`) or inside RCDATA would open a region
|
|
716
|
+
* that never closes, and every component after it would silently stop rendering.
|
|
717
|
+
* Deciding that requires knowing the context, which means tokenizing, so the
|
|
718
|
+
* pass tracks the same states the HTML parser does for these purposes:
|
|
719
|
+
*
|
|
720
|
+
* - **Comments**, including the spec's short forms. `<!-->` and `<!--->` close
|
|
721
|
+
* immediately, `--!>` closes as well as `-->`, and an unterminated comment
|
|
722
|
+
* runs to EOF, exactly as a browser would treat the same bytes.
|
|
723
|
+
* - **Markup declarations and bogus comments** (`<!doctype …>`, `<![CDATA[…]]>`),
|
|
724
|
+
* which end at the next `>`.
|
|
725
|
+
* - **Tags**, consumed with their quoted attribute values, so `<` and `<!--`
|
|
726
|
+
* inside an attribute are inert rather than context-changing.
|
|
727
|
+
* - **Text-only elements**, whose content the HTML tokenizer never reads as
|
|
728
|
+
* markup: raw text (`script`, `style`, `iframe`, `xmp`, `noembed`,
|
|
729
|
+
* `noframes`, `plaintext`) and RCDATA (`textarea`, `title`). Their content is
|
|
730
|
+
* returned as a skip range too, because a component tag inside a `<style>`
|
|
731
|
+
* comment or an `<iframe>` fallback hit the identical markup-destroying path,
|
|
732
|
+
* so excluding them would leave half the bug live.
|
|
733
|
+
*
|
|
734
|
+
* Two deliberate exclusions. `<template>` content IS parsed and legitimately
|
|
735
|
+
* carries components (Declarative Shadow DOM and the streamed swap templates
|
|
736
|
+
* both depend on that). `<noscript>` content is parsed as markup when
|
|
737
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
738
|
+
* case that matters, so components inside it must keep rendering.
|
|
739
|
+
*
|
|
740
|
+
* @param {string} html
|
|
741
|
+
* @returns {[number, number][]} ascending, non-overlapping `[start, end)` pairs
|
|
742
|
+
*/
|
|
743
|
+
function inertRanges(html) {
|
|
744
|
+
/** @type {[number, number][]} */
|
|
745
|
+
const ranges = [];
|
|
746
|
+
const n = html.length;
|
|
747
|
+
let i = 0;
|
|
748
|
+
while (i < n) {
|
|
749
|
+
const lt = html.indexOf('<', i);
|
|
750
|
+
if (lt === -1) break;
|
|
751
|
+
if (html.startsWith('<!--', lt)) {
|
|
752
|
+
const end = endOfComment(html, lt);
|
|
753
|
+
const stop = end === -1 ? n : end;
|
|
754
|
+
ranges.push([lt, stop]);
|
|
755
|
+
i = stop;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
if (html.startsWith('<!', lt) || html.startsWith('<?', lt)) {
|
|
759
|
+
// Doctype / bogus comment / processing instruction: ends at the next `>`.
|
|
760
|
+
const close = html.indexOf('>', lt);
|
|
761
|
+
const end = close === -1 ? n : close + 1;
|
|
762
|
+
ranges.push([lt, end]);
|
|
763
|
+
i = end;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(lt, lt + 64));
|
|
767
|
+
if (!name) {
|
|
768
|
+
// `</` followed by anything that is not an ASCII letter is the third
|
|
769
|
+
// bogus-comment form (`</1`, `</<`, `</ `), which the spec also runs to
|
|
770
|
+
// the next `>`. Without this branch the bytes after it are scanned as
|
|
771
|
+
// markup and a tag inside gets instantiated, which is the original bug.
|
|
772
|
+
if (html.startsWith('</', lt)) {
|
|
773
|
+
const close = html.indexOf('>', lt);
|
|
774
|
+
const end = close === -1 ? n : close + 1;
|
|
775
|
+
ranges.push([lt, end]);
|
|
776
|
+
i = end;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
i = lt + 1;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
// Consume the tag, honouring quoted attribute values so a `<` or `<!--`
|
|
783
|
+
// inside one cannot be mistaken for markup.
|
|
784
|
+
//
|
|
785
|
+
// A quote only OPENS a value when it directly follows `=`. That condition
|
|
786
|
+
// is load-bearing rather than pedantic: `escapeAttr` does not escape `'`,
|
|
787
|
+
// so an interpolated apostrophe in a single-quoted attribute emits three
|
|
788
|
+
// unbalanced quotes (`title='don't'`). Treating every quote as a delimiter
|
|
789
|
+
// left the scanner stuck inside a value to EOF, which returned a truncated
|
|
790
|
+
// range list and silently re-enabled this whole bug for the rest of the
|
|
791
|
+
// page. A browser recovers at the `>`, and so does this: after the value
|
|
792
|
+
// closes, the stray `'` is just an attribute-name character.
|
|
793
|
+
let p = lt + 1;
|
|
794
|
+
let quote = '';
|
|
795
|
+
// `expectValue` is set by `=` and cleared by the first non-whitespace
|
|
796
|
+
// character after it. Only THAT character can open a quoted value, which is
|
|
797
|
+
// what the spec does: before-attribute-value reconsumes anything else in
|
|
798
|
+
// attribute-value-unquoted state. Keying off "the previous character was
|
|
799
|
+
// `=`" instead re-opens the hole on `<a title==">`, where the `"` is an
|
|
800
|
+
// ordinary value character; an odd quote count then ran the scan to EOF and
|
|
801
|
+
// returned one giant inert range, silently disabling this whole fix for the
|
|
802
|
+
// rest of the page.
|
|
803
|
+
let expectValue = false;
|
|
804
|
+
// Unquoted values need their own state for two reasons the spec spells out
|
|
805
|
+
// and a simpler scan gets wrong: `>` ends the tag from here (so `attr=>` is
|
|
806
|
+
// a missing value, not a value of `>`), and `/` is an ordinary value
|
|
807
|
+
// character, so an unquoted URL ending in `/` is NOT a self-closing solidus.
|
|
808
|
+
let inUnquoted = false;
|
|
809
|
+
let selfClosing = false;
|
|
810
|
+
while (p < n) {
|
|
811
|
+
const c = html[p];
|
|
812
|
+
const isSpace = c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
813
|
+
if (quote) {
|
|
814
|
+
if (c === quote) quote = '';
|
|
815
|
+
} else if (c === '>') {
|
|
816
|
+
// Checked before the value branches: a `>` arriving where a value was
|
|
817
|
+
// expected terminates the tag (`<a href=>`). Consuming it as a value
|
|
818
|
+
// character ran the scan on to the NEXT `>`, which swallowed the real
|
|
819
|
+
// tag end and re-armed the original bug for what followed.
|
|
820
|
+
selfClosing = !inUnquoted && html[p - 1] === '/';
|
|
821
|
+
p += 1;
|
|
822
|
+
break;
|
|
823
|
+
} else if (expectValue) {
|
|
824
|
+
// The first non-whitespace character after `=` decides the value form.
|
|
825
|
+
if (!isSpace) {
|
|
826
|
+
if (c === '"' || c === "'") quote = c;
|
|
827
|
+
else inUnquoted = true;
|
|
828
|
+
expectValue = false;
|
|
829
|
+
}
|
|
830
|
+
} else if (inUnquoted) {
|
|
831
|
+
if (isSpace) inUnquoted = false;
|
|
832
|
+
} else if (c === '=') {
|
|
833
|
+
expectValue = true;
|
|
834
|
+
}
|
|
835
|
+
p += 1;
|
|
836
|
+
}
|
|
837
|
+
// The tag's interior is not markup either. A component tag written inside
|
|
838
|
+
// an attribute value (`title="renders a <my-card> element"`) was otherwise
|
|
839
|
+
// instantiated in place, destroying the rest of the document exactly like
|
|
840
|
+
// the comment case. Start at lt+1 so the tag's OWN opening `<` still
|
|
841
|
+
// matches; only what is nested inside it is inert.
|
|
842
|
+
if (p > lt + 1) ranges.push([lt + 1, p]);
|
|
843
|
+
i = p;
|
|
844
|
+
const tag = name[1].toLowerCase();
|
|
845
|
+
const isClose = html[lt + 1] === '/';
|
|
846
|
+
// A self-closing start tag has no content to skip. In HTML the `/` is
|
|
847
|
+
// ignored, but in SVG and MathML foreign content it genuinely closes the
|
|
848
|
+
// element, and `<svg><title/></svg>` otherwise finds no `</title`, runs the
|
|
849
|
+
// range to EOF, and makes every component in the rest of the document
|
|
850
|
+
// inert. Honouring `/>` costs only the malformed-HTML case (`<style/>`,
|
|
851
|
+
// already broken authoring) and fails in the direction where components
|
|
852
|
+
// keep rendering rather than silently vanishing.
|
|
853
|
+
if (!isClose && !selfClosing && isTextOnlyTag(tag)) {
|
|
854
|
+
// Everything up to the matching close tag is text, not markup.
|
|
855
|
+
let contentEnd;
|
|
856
|
+
if (tag === 'plaintext') {
|
|
857
|
+
// `<plaintext>` has no end tag at all: the rest of the document is text.
|
|
858
|
+
contentEnd = n;
|
|
859
|
+
} else if (tag === 'script') {
|
|
860
|
+
// Script data has the double-escaped state (#1134), so its real end is
|
|
861
|
+
// not necessarily the first `</script`.
|
|
862
|
+
const end = endOfScriptContent(html, p);
|
|
863
|
+
contentEnd = end === -1 ? n : end;
|
|
864
|
+
} else {
|
|
865
|
+
const close = new RegExp(`</${tag}(?=[\\s/>])`, 'i').exec(html.slice(p));
|
|
866
|
+
contentEnd = close ? p + close.index : n;
|
|
867
|
+
}
|
|
868
|
+
if (contentEnd > p) ranges.push([p, contentEnd]);
|
|
869
|
+
i = contentEnd;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return ranges;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Random-access membership test over ascending, non-overlapping ranges, for
|
|
877
|
+
* callers whose queries are NOT monotonic (findClosingTagInString resets its
|
|
878
|
+
* regex cursors backward while pairing opens with closes). O(ranges) per call;
|
|
879
|
+
* monotonic callers use `inertAt` below instead.
|
|
880
|
+
*
|
|
881
|
+
* @param {[number, number][]} ranges
|
|
882
|
+
* @param {number} index
|
|
883
|
+
* @returns {boolean}
|
|
884
|
+
*/
|
|
885
|
+
function inRanges(ranges, index) {
|
|
886
|
+
for (const [start, end] of ranges) {
|
|
887
|
+
if (start > index) return false;
|
|
888
|
+
if (index < end) return true;
|
|
889
|
+
}
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* A left-to-right membership test over ascending, non-overlapping ranges.
|
|
895
|
+
*
|
|
896
|
+
* Returns a function that answers "is this index inert?" and REMEMBERS how far
|
|
897
|
+
* it has walked, so a caller scanning matches in increasing order pays O(ranges)
|
|
898
|
+
* across the whole scan instead of O(ranges) per match. Restarting each time is
|
|
899
|
+
* an O(tags x components) term, which is measurable on a large page: holding a
|
|
900
|
+
* document at 40k tags and raising the component count adds hundreds of
|
|
901
|
+
* milliseconds that the cursor removes.
|
|
902
|
+
*
|
|
903
|
+
* The cursor only ever moves forward, so callers MUST query in non-decreasing
|
|
904
|
+
* index order. All three call sites do (`matchAll`, and the two loops that
|
|
905
|
+
* consume their input left to right). A caller that needs random access should
|
|
906
|
+
* scan `ranges` directly rather than reusing this.
|
|
907
|
+
*
|
|
908
|
+
* @param {[number, number][]} ranges
|
|
909
|
+
* @returns {(index: number) => boolean}
|
|
910
|
+
*/
|
|
911
|
+
function inertAt(ranges) {
|
|
912
|
+
let cursor = 0;
|
|
913
|
+
return (index) => {
|
|
914
|
+
while (cursor < ranges.length && ranges[cursor][1] <= index) cursor += 1;
|
|
915
|
+
if (cursor >= ranges.length) return false;
|
|
916
|
+
return index >= ranges[cursor][0];
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
414
920
|
/**
|
|
415
921
|
* Scan an HTML string for registered custom elements and inject
|
|
416
922
|
* Declarative Shadow DOM (`<template shadowrootmode="open">`).
|
|
@@ -447,10 +953,15 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
447
953
|
);
|
|
448
954
|
/** @type {{start:number, end:number, text:string}[]} */
|
|
449
955
|
const edits = [];
|
|
956
|
+
// A tag name inside a comment, a script, a style, RCDATA, or another tag's
|
|
957
|
+
// attribute value is text, not an element (#1128).
|
|
958
|
+
const inert = inertRanges(html);
|
|
959
|
+
const isInert = inertAt(inert);
|
|
450
960
|
for (const m of html.matchAll(pattern)) {
|
|
451
961
|
const [match, tag, attrs, selfClose] = m;
|
|
452
962
|
const Cls = lookup(tag);
|
|
453
963
|
if (!Cls) continue;
|
|
964
|
+
if (isInert(m.index)) continue;
|
|
454
965
|
// Track which custom elements actually appeared: used by SSR to emit
|
|
455
966
|
// `<link rel="modulepreload">` hints for their module URLs.
|
|
456
967
|
if (ctx && ctx.usedComponents) ctx.usedComponents.add(tag);
|
|
@@ -496,7 +1007,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
496
1007
|
let closeEnd = m.index + match.length;
|
|
497
1008
|
if (!selfClose) {
|
|
498
1009
|
const innerStart = m.index + match.length;
|
|
499
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
1010
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
500
1011
|
if (closeIdx !== -1) {
|
|
501
1012
|
authoredInner = html.slice(innerStart, closeIdx);
|
|
502
1013
|
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
@@ -549,7 +1060,14 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
549
1060
|
// Render the template to HTML. injectDSD recurses on the result so
|
|
550
1061
|
// nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
|
|
551
1062
|
// get their own DSD pass.
|
|
552
|
-
|
|
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');
|
|
553
1071
|
|
|
554
1072
|
if (isShadow) {
|
|
555
1073
|
// Shadow DOM: native <slot> stays as-is in the DSD template. The
|
|
@@ -644,7 +1162,7 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
|
|
|
644
1162
|
let closeEnd = m.index + match.length;
|
|
645
1163
|
if (!selfClose) {
|
|
646
1164
|
const innerStart = m.index + match.length;
|
|
647
|
-
const closeIdx = findClosingTagInString(html, innerStart, tag);
|
|
1165
|
+
const closeIdx = findClosingTagInString(html, innerStart, tag, inert);
|
|
648
1166
|
if (closeIdx !== -1) {
|
|
649
1167
|
const closeRe = new RegExp(`</${escapeRegex(tag)}\\s*>`, 'i');
|
|
650
1168
|
const cm = closeRe.exec(html.slice(closeIdx));
|
|
@@ -753,6 +1271,17 @@ function isVoidElement(tag) {
|
|
|
753
1271
|
async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
754
1272
|
if (html.indexOf('<webjs-suspense') === -1) return html;
|
|
755
1273
|
const OPEN = /<webjs-suspense((?:"[^"]*"|'[^']*'|[^>])*?)>/i;
|
|
1274
|
+
// A commented-out boundary is text, not an element (#1128). This scanner is
|
|
1275
|
+
// the reason the comment fix cannot live in injectDSD alone: it runs FIRST
|
|
1276
|
+
// and hands the boundary's children to a fresh injectDSD as a standalone
|
|
1277
|
+
// string, which has no idea those bytes came from inside a comment. Under
|
|
1278
|
+
// streaming it is worse than a stray render, because the children's data
|
|
1279
|
+
// fetches run and the swap script targets an id that only exists inside a
|
|
1280
|
+
// comment, so it can never resolve. Ranges are computed against the FULL
|
|
1281
|
+
// input once, and `consumed` maps the shrinking `rest` back onto it.
|
|
1282
|
+
const inert = inertRanges(html);
|
|
1283
|
+
const isInert = inertAt(inert);
|
|
1284
|
+
let consumed = 0;
|
|
756
1285
|
let result = '';
|
|
757
1286
|
let rest = html;
|
|
758
1287
|
// Bounded loop: each iteration consumes at least the opening tag.
|
|
@@ -762,6 +1291,14 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
762
1291
|
result += rest;
|
|
763
1292
|
break;
|
|
764
1293
|
}
|
|
1294
|
+
if (isInert(consumed + m.index)) {
|
|
1295
|
+
// Emit through the end of this match and keep scanning after it.
|
|
1296
|
+
const skipTo = m.index + m[0].length;
|
|
1297
|
+
result += rest.slice(0, skipTo);
|
|
1298
|
+
rest = rest.slice(skipTo);
|
|
1299
|
+
consumed += skipTo;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
765
1302
|
const openStart = m.index;
|
|
766
1303
|
const openEnd = m.index + m[0].length;
|
|
767
1304
|
result += rest.slice(0, openStart);
|
|
@@ -769,7 +1306,20 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
769
1306
|
const fbMatch = /data-webjs-fallback="([^"]*)"/i.exec(attrs);
|
|
770
1307
|
const fallbackHtml = fbMatch ? unescapeAttr(fbMatch[1]) : '';
|
|
771
1308
|
|
|
772
|
-
|
|
1309
|
+
// Pass the FULL-input ranges shifted into `rest` coordinates rather than
|
|
1310
|
+
// letting the helper re-tokenize the suffix. `rest` can begin mid-comment
|
|
1311
|
+
// or mid-raw-text after the skip path above, and a tokenizer restarted
|
|
1312
|
+
// there is in the wrong state: a text-only opener named later in that same
|
|
1313
|
+
// comment would read as a real element and mark everything to EOF inert,
|
|
1314
|
+
// so the boundary's real close tag was skipped and the trailing markup
|
|
1315
|
+
// folded into the boundary. The shifted view keeps the full-string truth,
|
|
1316
|
+
// including a first range that starts before `rest` does.
|
|
1317
|
+
const shifted = [];
|
|
1318
|
+
for (const [s, e] of inert) {
|
|
1319
|
+
if (e <= consumed) continue;
|
|
1320
|
+
shifted.push([Math.max(0, s - consumed), e - consumed]);
|
|
1321
|
+
}
|
|
1322
|
+
const closeIdx = findClosingTagInString(rest, openEnd, 'webjs-suspense', shifted);
|
|
773
1323
|
let inner;
|
|
774
1324
|
let afterClose;
|
|
775
1325
|
if (closeIdx === -1) {
|
|
@@ -792,6 +1342,7 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
792
1342
|
const innerProcessed = await injectDSD(inner, ctx, ancestors, dev);
|
|
793
1343
|
result += `<webjs-suspense>${innerProcessed}</webjs-suspense>`;
|
|
794
1344
|
}
|
|
1345
|
+
consumed += rest.length - afterClose.length;
|
|
795
1346
|
rest = afterClose;
|
|
796
1347
|
}
|
|
797
1348
|
return result;
|
|
@@ -807,18 +1358,34 @@ async function processSuspenseElements(html, ctx, ancestors = [], dev) {
|
|
|
807
1358
|
* @param {string} tagName
|
|
808
1359
|
* @returns {number}
|
|
809
1360
|
*/
|
|
810
|
-
function findClosingTagInString(html, fromIndex, tagName) {
|
|
1361
|
+
function findClosingTagInString(html, fromIndex, tagName, inert) {
|
|
811
1362
|
const esc = escapeRegex(tagName);
|
|
812
1363
|
// Match same-name opening tags. Followed by a name-boundary character
|
|
813
1364
|
// so we don't accept <table> as opening <tab>.
|
|
814
1365
|
const openRe = new RegExp(`<${esc}(?:[\\s>/])`, 'gi');
|
|
815
1366
|
const closeRe = new RegExp(`</${esc}\\s*>`, 'gi');
|
|
1367
|
+
// A tag inside a comment, raw text, RCDATA, or an attribute value is text
|
|
1368
|
+
// and must count for NEITHER side of the depth ledger (#1133). Counting a
|
|
1369
|
+
// commented `<my-card>` as a nested open meant depth never returned to zero,
|
|
1370
|
+
// and matching a commented `</my-card>` as the close truncated the authored
|
|
1371
|
+
// children at the comment, so the projected content ended with an
|
|
1372
|
+
// unterminated `<!--` that a browser read as commenting out the real close
|
|
1373
|
+
// tags. Callers that already computed the ranges for this exact string pass
|
|
1374
|
+
// them; a caller that did not gets them computed here.
|
|
1375
|
+
const ranges = inert === undefined ? inertRanges(html) : inert;
|
|
1376
|
+
const next = (re) => {
|
|
1377
|
+
let m;
|
|
1378
|
+
while ((m = re.exec(html)) !== null) {
|
|
1379
|
+
if (ranges.length === 0 || !inRanges(ranges, m.index)) return m;
|
|
1380
|
+
}
|
|
1381
|
+
return null;
|
|
1382
|
+
};
|
|
816
1383
|
openRe.lastIndex = fromIndex;
|
|
817
1384
|
closeRe.lastIndex = fromIndex;
|
|
818
1385
|
let depth = 1;
|
|
819
1386
|
while (depth > 0) {
|
|
820
|
-
const o = openRe
|
|
821
|
-
const c = closeRe
|
|
1387
|
+
const o = next(openRe);
|
|
1388
|
+
const c = next(closeRe);
|
|
822
1389
|
if (!c) return -1;
|
|
823
1390
|
if (o && o.index < c.index) {
|
|
824
1391
|
depth++;
|
|
@@ -875,14 +1442,20 @@ function partitionAuthoredBySlot(html) {
|
|
|
875
1442
|
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
876
1443
|
const rest = html.slice(lt);
|
|
877
1444
|
if (rest.startsWith('<!--')) {
|
|
878
|
-
|
|
879
|
-
|
|
1445
|
+
// Find the comment's end the same way inertRanges does, rather than with
|
|
1446
|
+
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
1447
|
+
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
1448
|
+
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
1449
|
+
// swallow the slotted children that follow, silently routing a
|
|
1450
|
+
// `slot="head"` child into the default slot.
|
|
1451
|
+
const commentEnd = endOfComment(html, lt);
|
|
1452
|
+
if (commentEnd === -1) {
|
|
880
1453
|
defaultBuf += rest;
|
|
881
1454
|
cursor = html.length;
|
|
882
1455
|
break;
|
|
883
1456
|
}
|
|
884
|
-
defaultBuf += html.slice(lt,
|
|
885
|
-
cursor =
|
|
1457
|
+
defaultBuf += html.slice(lt, commentEnd);
|
|
1458
|
+
cursor = commentEnd;
|
|
886
1459
|
continue;
|
|
887
1460
|
}
|
|
888
1461
|
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
@@ -968,8 +1541,16 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
|
968
1541
|
let result = '';
|
|
969
1542
|
let cursor = 0;
|
|
970
1543
|
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
1544
|
+
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
1545
|
+
// Substituting one is worse here than in the element walk: a commented slot
|
|
1546
|
+
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
1547
|
+
// template, the component's REAL slot is never substituted, and the authored
|
|
1548
|
+
// children are dropped from the page entirely.
|
|
1549
|
+
const inert = inertRanges(rendered);
|
|
1550
|
+
const isInert = inertAt(inert);
|
|
971
1551
|
let m;
|
|
972
1552
|
while ((m = slotRe.exec(rendered)) !== null) {
|
|
1553
|
+
if (isInert(m.index)) continue;
|
|
973
1554
|
result += rendered.slice(cursor, m.index);
|
|
974
1555
|
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
975
1556
|
const isSelfClose = !!selfCloseSlash;
|
|
@@ -984,7 +1565,7 @@ function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
|
984
1565
|
totalEnd = m.index + fullOpen.length;
|
|
985
1566
|
} else {
|
|
986
1567
|
const innerStart = m.index + fullOpen.length;
|
|
987
|
-
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot');
|
|
1568
|
+
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
988
1569
|
if (closeIdx === -1) {
|
|
989
1570
|
fallback = rendered.slice(innerStart);
|
|
990
1571
|
totalEnd = rendered.length;
|
|
@@ -1028,6 +1609,41 @@ function isRawtextTag(tag) {
|
|
|
1028
1609
|
return tag === 'script' || tag === 'style';
|
|
1029
1610
|
}
|
|
1030
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* RCDATA elements: their content is text (character references aside), so a
|
|
1614
|
+
* tag-shaped string inside one is not markup. Kept next to `isRawtextTag` so
|
|
1615
|
+
* the two lists stay together rather than drifting apart.
|
|
1616
|
+
* @param {string} tag
|
|
1617
|
+
* @returns {boolean}
|
|
1618
|
+
*/
|
|
1619
|
+
function isRcdataTag(tag) {
|
|
1620
|
+
return tag === 'textarea' || tag === 'title';
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Elements whose content the HTML tokenizer never reads as markup, for the
|
|
1625
|
+
* purposes of `inertRanges` only (#1128).
|
|
1626
|
+
*
|
|
1627
|
+
* Deliberately NOT `isRawtextTag`, even though it overlaps: that predicate is
|
|
1628
|
+
* shared with the template tokenizer, where widening it would change how holes
|
|
1629
|
+
* inside those elements are escaped. This one answers a narrower question,
|
|
1630
|
+
* "can a tag-shaped string in here be a real element", and the answer is no for
|
|
1631
|
+
* every raw-text and RCDATA element, not just the two the template path cares
|
|
1632
|
+
* about. `<iframe>` with fallback markup is the realistic trigger.
|
|
1633
|
+
*
|
|
1634
|
+
* `<noscript>` is excluded on purpose: its content IS parsed as markup when
|
|
1635
|
+
* scripting is disabled, which for a progressive-enhancement framework is the
|
|
1636
|
+
* case that matters, so components inside it must keep rendering.
|
|
1637
|
+
*
|
|
1638
|
+
* @param {string} tag
|
|
1639
|
+
* @returns {boolean}
|
|
1640
|
+
*/
|
|
1641
|
+
function isTextOnlyTag(tag) {
|
|
1642
|
+
return isRawtextTag(tag) || isRcdataTag(tag)
|
|
1643
|
+
|| tag === 'iframe' || tag === 'xmp' || tag === 'noembed'
|
|
1644
|
+
|| tag === 'noframes' || tag === 'plaintext';
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1031
1647
|
/**
|
|
1032
1648
|
* Minimal attribute string parser.
|
|
1033
1649
|
* @param {string} attrStr
|
|
@@ -1244,7 +1860,7 @@ export function renderToStream(value, opts = { ssr: true }) {
|
|
|
1244
1860
|
try {
|
|
1245
1861
|
if (opts && opts.ssr === false) {
|
|
1246
1862
|
// No DSD injection: just stream the raw rendered chunks.
|
|
1247
|
-
await streamRender(value, ctx, controller);
|
|
1863
|
+
await streamRender(value, ctx, controller, 'none');
|
|
1248
1864
|
} else {
|
|
1249
1865
|
// Render to string first to run DSD injection (which operates on
|
|
1250
1866
|
// the full HTML), then enqueue the result. This matches the
|
|
@@ -1273,28 +1889,29 @@ export function renderToStream(value, opts = { ssr: true }) {
|
|
|
1273
1889
|
* @param {unknown} value
|
|
1274
1890
|
* @param {SuspenseCtx} [ctx]
|
|
1275
1891
|
* @param {ReadableStreamDefaultController<string>} controller
|
|
1892
|
+
* @param {'none'|'unbound'|'bound'|'unknown'} [formScope] enclosing form scope, as seen from THIS scan
|
|
1276
1893
|
*/
|
|
1277
|
-
async function streamRender(value, ctx, controller) {
|
|
1894
|
+
async function streamRender(value, ctx, controller, formScope = 'none') {
|
|
1278
1895
|
if (value == null || value === false || value === true) return;
|
|
1279
1896
|
if (value && typeof /** @type any */ (value).then === 'function') {
|
|
1280
1897
|
value = await value;
|
|
1281
|
-
return streamRender(value, ctx, controller);
|
|
1898
|
+
return streamRender(value, ctx, controller, formScope);
|
|
1282
1899
|
}
|
|
1283
1900
|
if (isUnsafeHTML(value)) {
|
|
1284
1901
|
controller.enqueue(String(/** @type any */ (value).value ?? ''));
|
|
1285
1902
|
return;
|
|
1286
1903
|
}
|
|
1287
1904
|
if (isLive(value)) {
|
|
1288
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1905
|
+
return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
|
|
1289
1906
|
}
|
|
1290
1907
|
if (isWatch(value)) {
|
|
1291
|
-
return streamRender(/** @type any */ (value).signal.get(), ctx, controller);
|
|
1908
|
+
return streamRender(/** @type any */ (value).signal.get(), ctx, controller, formScope);
|
|
1292
1909
|
}
|
|
1293
1910
|
if (isKeyed(value)) {
|
|
1294
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1911
|
+
return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
|
|
1295
1912
|
}
|
|
1296
1913
|
if (isGuard(value)) {
|
|
1297
|
-
return streamRender(/** @type any */ (value).fn(), ctx, controller);
|
|
1914
|
+
return streamRender(/** @type any */ (value).fn(), ctx, controller, formScope);
|
|
1298
1915
|
}
|
|
1299
1916
|
if (isTemplateContent(value)) {
|
|
1300
1917
|
const tpl = /** @type any */ (value).template;
|
|
@@ -1305,19 +1922,19 @@ async function streamRender(value, ctx, controller) {
|
|
|
1305
1922
|
return;
|
|
1306
1923
|
}
|
|
1307
1924
|
if (isCache(value)) {
|
|
1308
|
-
return streamRender(/** @type any */ (value).value, ctx, controller);
|
|
1925
|
+
return streamRender(/** @type any */ (value).value, ctx, controller, formScope);
|
|
1309
1926
|
}
|
|
1310
1927
|
if (isUntil(value)) {
|
|
1311
1928
|
const args = /** @type any */ (value).args;
|
|
1312
1929
|
for (const a of args) {
|
|
1313
1930
|
if (!a || typeof (/** @type any */ (a).then) !== 'function') {
|
|
1314
|
-
return streamRender(a, ctx, controller);
|
|
1931
|
+
return streamRender(a, ctx, controller, formScope);
|
|
1315
1932
|
}
|
|
1316
1933
|
}
|
|
1317
1934
|
if (args.length > 0) {
|
|
1318
1935
|
try {
|
|
1319
1936
|
const winner = await Promise.race(args.map((p) => Promise.resolve(p).catch(() => undefined)));
|
|
1320
|
-
return streamRender(winner, ctx, controller);
|
|
1937
|
+
return streamRender(winner, ctx, controller, formScope);
|
|
1321
1938
|
} catch {
|
|
1322
1939
|
return;
|
|
1323
1940
|
}
|
|
@@ -1328,13 +1945,13 @@ async function streamRender(value, ctx, controller) {
|
|
|
1328
1945
|
return;
|
|
1329
1946
|
}
|
|
1330
1947
|
if (Array.isArray(value)) {
|
|
1331
|
-
for (const v of value) await streamRender(v, ctx, controller);
|
|
1948
|
+
for (const v of value) await streamRender(v, ctx, controller, formScope);
|
|
1332
1949
|
return;
|
|
1333
1950
|
}
|
|
1334
1951
|
if (isRepeat(value)) {
|
|
1335
1952
|
const r = /** @type any */ (value);
|
|
1336
1953
|
for (let i = 0; i < r.items.length; i++) {
|
|
1337
|
-
await streamRender(r.templateFn(r.items[i], i), ctx, controller);
|
|
1954
|
+
await streamRender(r.templateFn(r.items[i], i), ctx, controller, formScope);
|
|
1338
1955
|
}
|
|
1339
1956
|
return;
|
|
1340
1957
|
}
|
|
@@ -1343,16 +1960,16 @@ async function streamRender(value, ctx, controller) {
|
|
|
1343
1960
|
if (ctx) {
|
|
1344
1961
|
const id = `s${ctx.nextId++}`;
|
|
1345
1962
|
controller.enqueue(`<webjs-boundary id="${id}">`);
|
|
1346
|
-
await streamRender(s.fallback, ctx, controller);
|
|
1963
|
+
await streamRender(s.fallback, ctx, controller, formScope);
|
|
1347
1964
|
controller.enqueue(`</webjs-boundary>`);
|
|
1348
|
-
ctx.pending.push({ id, promise: Promise.resolve(s.children) });
|
|
1965
|
+
ctx.pending.push({ id, promise: Promise.resolve(s.children), formScope });
|
|
1349
1966
|
} else {
|
|
1350
|
-
await streamRender(s.fallback, ctx, controller);
|
|
1967
|
+
await streamRender(s.fallback, ctx, controller, formScope);
|
|
1351
1968
|
}
|
|
1352
1969
|
return;
|
|
1353
1970
|
}
|
|
1354
1971
|
if (isTemplate(value)) {
|
|
1355
|
-
await streamTemplate(/** @type any */ (value), ctx, controller);
|
|
1972
|
+
await streamTemplate(/** @type any */ (value), ctx, controller, formScope);
|
|
1356
1973
|
return;
|
|
1357
1974
|
}
|
|
1358
1975
|
controller.enqueue(escapeText(String(value)));
|
|
@@ -1365,8 +1982,9 @@ async function streamRender(value, ctx, controller) {
|
|
|
1365
1982
|
* @param {import('./html.js').TemplateResult} tr
|
|
1366
1983
|
* @param {SuspenseCtx} [ctx]
|
|
1367
1984
|
* @param {ReadableStreamDefaultController<string>} controller
|
|
1985
|
+
* @param {'none'|'unbound'|'bound'|'unknown'} [formScopeAtStart]
|
|
1368
1986
|
*/
|
|
1369
|
-
async function streamTemplate(tr, ctx, controller) {
|
|
1987
|
+
async function streamTemplate(tr, ctx, controller, formScopeAtStart = 'none') {
|
|
1370
1988
|
const { strings, values } = tr;
|
|
1371
1989
|
let state = 'text';
|
|
1372
1990
|
let attrName = '';
|
|
@@ -1377,6 +1995,101 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1377
1995
|
let rawTail = '';
|
|
1378
1996
|
// Buffer used for attribute handling where we may need to backtrack.
|
|
1379
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
|
+
};
|
|
1380
2093
|
|
|
1381
2094
|
for (let i = 0; i < strings.length; i++) {
|
|
1382
2095
|
const s = strings[i];
|
|
@@ -1385,12 +2098,12 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1385
2098
|
switch (state) {
|
|
1386
2099
|
case 'text':
|
|
1387
2100
|
buf += c;
|
|
1388
|
-
if (c === '<') state = 'tag-open';
|
|
2101
|
+
if (c === '<') { state = 'tag-open'; tagStart = buf.length - 1; isCloseTag = false; }
|
|
1389
2102
|
break;
|
|
1390
2103
|
case 'tag-open':
|
|
1391
2104
|
buf += c;
|
|
1392
2105
|
if (c === '!') state = 'bang-1';
|
|
1393
|
-
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
|
|
2106
|
+
else if (c === '/') { state = 'tag-name'; currentTag = ''; isCloseTag = true; }
|
|
1394
2107
|
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
|
|
1395
2108
|
else state = 'text';
|
|
1396
2109
|
break;
|
|
@@ -1412,16 +2125,14 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1412
2125
|
case 'tag-name':
|
|
1413
2126
|
buf += c;
|
|
1414
2127
|
if (c === '>') {
|
|
1415
|
-
|
|
1416
|
-
if (state === 'rawtext') rawTail = '';
|
|
2128
|
+
handleTagEnd(true);
|
|
1417
2129
|
} else if (/\s/.test(c)) state = 'in-tag';
|
|
1418
2130
|
else currentTag += c.toLowerCase();
|
|
1419
2131
|
break;
|
|
1420
2132
|
case 'in-tag':
|
|
1421
2133
|
buf += c;
|
|
1422
2134
|
if (c === '>') {
|
|
1423
|
-
|
|
1424
|
-
if (state === 'rawtext') rawTail = '';
|
|
2135
|
+
handleTagEnd(true);
|
|
1425
2136
|
} else if (!/\s/.test(c) && c !== '/') {
|
|
1426
2137
|
state = 'attr-name';
|
|
1427
2138
|
attrName = c;
|
|
@@ -1440,18 +2151,18 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1440
2151
|
case 'attr-name':
|
|
1441
2152
|
if (c === '=') { state = 'after-eq'; buf += c; }
|
|
1442
2153
|
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1443
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
2154
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
1444
2155
|
else { attrName += c; buf += c; }
|
|
1445
2156
|
break;
|
|
1446
2157
|
case 'after-eq':
|
|
1447
2158
|
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
|
|
1448
2159
|
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1449
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
2160
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
1450
2161
|
else { state = 'attr-unquoted'; buf += c; }
|
|
1451
2162
|
break;
|
|
1452
2163
|
case 'attr-unquoted':
|
|
1453
2164
|
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
|
|
1454
|
-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
|
|
2165
|
+
else if (c === '>') { state = 'text'; attrName = ''; buf += c; handleTagEnd(false); }
|
|
1455
2166
|
else buf += c;
|
|
1456
2167
|
break;
|
|
1457
2168
|
case 'attr-quoted':
|
|
@@ -1478,26 +2189,92 @@ async function streamTemplate(tr, ctx, controller) {
|
|
|
1478
2189
|
} else if (state === 'text') {
|
|
1479
2190
|
// Flush the buffered static content before streaming the value.
|
|
1480
2191
|
if (buf) { controller.enqueue(buf); buf = ''; }
|
|
1481
|
-
await streamRender(val, ctx, controller);
|
|
2192
|
+
await streamRender(val, ctx, controller, formScope);
|
|
1482
2193
|
} else if (state === 'after-eq') {
|
|
1483
2194
|
const prefix = attrName[0];
|
|
1484
2195
|
const name = attrName.slice(1);
|
|
1485
2196
|
const kind = BINDING_PREFIXES[prefix];
|
|
1486
2197
|
if (kind === 'event' || kind === 'prop') {
|
|
2198
|
+
// Guard `prop` ONLY, matching the buffered machine. An `@action`
|
|
2199
|
+
// event binding is dropped here and never stringified, and a
|
|
2200
|
+
// function is the LEGITIMATE value for one (`<my-el @action=${fn}>`
|
|
2201
|
+
// listens for an `action` event), so refusing it would be a false
|
|
2202
|
+
// positive. `.action` differs where the property REFLECTS: on a form
|
|
2203
|
+
// (and `.formAction` on a button or input) the client assignment
|
|
2204
|
+
// writes the source into the DOM, so refusing at SSR keeps a page
|
|
2205
|
+
// from rendering clean on the server and throwing on hydration.
|
|
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.
|
|
2222
|
+
if (kind === 'prop' && !currentTag.includes('-')) {
|
|
2223
|
+
assertNotFunctionReflectedActionProp(val, name, currentTag);
|
|
2224
|
+
notePropAttr(name, currentTag);
|
|
2225
|
+
}
|
|
1487
2226
|
buf = buf.slice(0, attrStart);
|
|
1488
2227
|
state = 'in-tag';
|
|
1489
2228
|
attrName = '';
|
|
1490
2229
|
} else if (kind === 'bool') {
|
|
2230
|
+
// A boolean binding stringifies nothing, so this never leaked, but
|
|
2231
|
+
// `?action=${fn}` is meaningless in every case (a truthy function
|
|
2232
|
+
// emits a bare `action=""`), and refusing it keeps the rule the docs
|
|
2233
|
+
// state true for every sigil rather than true only when quoted.
|
|
2234
|
+
assertNotFunctionActionAttr(val, name, currentTag);
|
|
1491
2235
|
buf = buf.slice(0, attrStart);
|
|
1492
2236
|
if (val) buf += `${name}=""`;
|
|
1493
2237
|
state = 'in-tag';
|
|
1494
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 = '';
|
|
1495
2264
|
} else {
|
|
2265
|
+
noteActionHole(attrName, currentTag);
|
|
2266
|
+
// The SAME guard as the buffered renderer above. This is a second,
|
|
2267
|
+
// independent state machine, so it inherits nothing from that one;
|
|
2268
|
+
// a change to the rule has to land in both. Reached only via
|
|
2269
|
+
// `renderToStream(v, { ssr: false })`, which no page render uses, so
|
|
2270
|
+
// this covers the public API surface rather than a page leak.
|
|
2271
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
1496
2272
|
buf += `"${escapeAttr(String(val ?? ''))}"`;
|
|
1497
2273
|
state = 'in-tag';
|
|
1498
2274
|
attrName = '';
|
|
1499
2275
|
}
|
|
1500
2276
|
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
2277
|
+
assertNotFunctionActionAttr(val, attrName, currentTag);
|
|
1501
2278
|
buf += escapeAttr(String(val ?? ''));
|
|
1502
2279
|
}
|
|
1503
2280
|
}
|
|
@@ -1529,10 +2306,10 @@ async function streamSuspenseBoundaries(ctx, controller, dev) {
|
|
|
1529
2306
|
while (ctx.pending.length) {
|
|
1530
2307
|
const batch = ctx.pending.splice(0);
|
|
1531
2308
|
await Promise.all(
|
|
1532
|
-
batch.map(async ({ id, promise }) => {
|
|
2309
|
+
batch.map(async ({ id, promise, formScope }) => {
|
|
1533
2310
|
try {
|
|
1534
2311
|
const resolved = await promise;
|
|
1535
|
-
const html = await render(resolved, ctx);
|
|
2312
|
+
const html = await render(resolved, ctx, formScope);
|
|
1536
2313
|
const full = await injectDSD(html, ctx, [], dev);
|
|
1537
2314
|
controller.enqueue(
|
|
1538
2315
|
`<template data-webjs-resolve="${id}">${full}</template>` +
|