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