@webjsdev/core 0.7.63 → 0.7.65
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 +4 -4
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +21 -22
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -7
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +1 -1
- package/index.d.ts +2 -2
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/attribute-reader.js +20 -0
- package/src/binding-prefixes.js +20 -1
- package/src/component/lifecycle.js +90 -48
- package/src/escape.js +12 -2
- package/src/form-action.js +7 -7
- package/src/optimistic.js +3 -3
- package/src/registry.d.ts +5 -0
- package/src/registry.js +35 -4
- package/src/render-client/parts.js +2 -2
- package/src/render-server/attrs.js +5 -4
- package/src/render-server/compile.js +419 -0
- package/src/render-server/dsd.js +11 -435
- package/src/render-server/errors.js +84 -0
- package/src/render-server/expand.js +319 -0
- package/src/render-server/html-scan.js +19 -358
- package/src/render-server/stream.js +11 -13
- package/src/render-server/template-renderer.js +13 -936
- package/src/render-server/walk.js +758 -0
- package/src/router-client/dom-differ.js +25 -2
- package/src/router-client/form-state.js +190 -0
- package/src/router-client/navigator.js +12 -0
- package/src/router-client/snapshot-cache.js +2 -12
- package/src/routes.d.ts +2 -0
- package/src/slot.js +1 -1
- package/src/render-server/slots.js +0 -213
|
@@ -554,11 +554,15 @@ export function reconcileChildren(dst, src) {
|
|
|
554
554
|
keyedLive.delete(k);
|
|
555
555
|
continue;
|
|
556
556
|
}
|
|
557
|
-
// Positional match: same tag, same index, neither has a key.
|
|
557
|
+
// Positional match: same tag, same index, neither has a key. A form
|
|
558
|
+
// control must also be the SAME control (#1581): reusing the `email`
|
|
559
|
+
// input for an incoming `name` input carries the live typed value into
|
|
560
|
+
// the wrong field, because `value` is a live attribute the diff keeps.
|
|
558
561
|
const livePeer = liveChildren[i];
|
|
559
562
|
if (livePeer && livePeer.nodeType === 1 &&
|
|
560
563
|
!keyOf(/** @type {Element} */ (livePeer)) &&
|
|
561
|
-
/** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName
|
|
564
|
+
/** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName &&
|
|
565
|
+
sameControlIdentity(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc))) {
|
|
562
566
|
diffElementInPlace(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc));
|
|
563
567
|
finalNodes.push(livePeer);
|
|
564
568
|
continue;
|
|
@@ -597,6 +601,25 @@ export function reconcileChildren(dst, src) {
|
|
|
597
601
|
}
|
|
598
602
|
}
|
|
599
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Whether two same-tag elements may stand for each other in a positional
|
|
606
|
+
* match. Anything but a form control always may. An `<input>`, `<select>` or
|
|
607
|
+
* `<textarea>` must agree on `name` (and an input on `type`), since the
|
|
608
|
+
* differ keeps a reused control's live value and a mismatch would move typed
|
|
609
|
+
* text into another field (#1581).
|
|
610
|
+
*
|
|
611
|
+
* @param {Element} a
|
|
612
|
+
* @param {Element} b
|
|
613
|
+
* @returns {boolean}
|
|
614
|
+
*/
|
|
615
|
+
function sameControlIdentity(a, b) {
|
|
616
|
+
const tag = a.localName;
|
|
617
|
+
if (tag !== 'input' && tag !== 'select' && tag !== 'textarea') return true;
|
|
618
|
+
if (a.getAttribute('name') !== b.getAttribute('name')) return false;
|
|
619
|
+
if (tag === 'input' && (a.getAttribute('type') || 'text').toLowerCase() !== (b.getAttribute('type') || 'text').toLowerCase()) return false;
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
|
|
600
623
|
/**
|
|
601
624
|
* Get the diff key for an element: `data-key` if present, else `id`.
|
|
602
625
|
* Returns null for elements with no stable key.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client router: keep what a person typed across a failed submission (#1581).
|
|
3
|
+
*
|
|
4
|
+
* A bound form that fails validation is answered with a 422 re-render, which
|
|
5
|
+
* the router applies in place. The differ keeps a control's live state only
|
|
6
|
+
* when it reuses the old node (a keyed match, or the same tag at the same
|
|
7
|
+
* index), so an error message inserted above the inputs shifts every position
|
|
8
|
+
* and the inputs come back as fresh server markup: empty, unless the page
|
|
9
|
+
* refilled each one from `actionData.values`. Pages forget a field often
|
|
10
|
+
* enough that the framework should not depend on it.
|
|
11
|
+
*
|
|
12
|
+
* So the router snapshots the submitted form's controls at submit time and,
|
|
13
|
+
* after a non-2xx response is applied, writes the typed state back into the
|
|
14
|
+
* re-rendered form. A control is restored only when its server-rendered
|
|
15
|
+
* DEFAULT (the `value` attribute, `checked`, an option's `selected`, a
|
|
16
|
+
* textarea's text) is the same as before the submission. A default that
|
|
17
|
+
* changed means the server rendered that control on purpose (refilled it,
|
|
18
|
+
* normalized it, reset it), and the server wins.
|
|
19
|
+
*
|
|
20
|
+
* Never restored: password inputs (re-entering one after a failure is the
|
|
21
|
+
* convention, and nothing the server renders can say otherwise), file inputs
|
|
22
|
+
* (a script cannot set them), hidden inputs (server-owned), and buttons. A
|
|
23
|
+
* form opts out with `data-preserve-values="false"`.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const FORM_ACTION_FIELD = '__webjs_action';
|
|
29
|
+
const SKIP_TYPES = new Set(['password', 'file', 'hidden', 'submit', 'button', 'image', 'reset']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {{
|
|
33
|
+
* name: string,
|
|
34
|
+
* kind: 'text' | 'check' | 'select',
|
|
35
|
+
* n: number,
|
|
36
|
+
* value?: string,
|
|
37
|
+
* def: string,
|
|
38
|
+
* cur: string,
|
|
39
|
+
* }} ControlState
|
|
40
|
+
* @typedef {{ form: HTMLFormElement, key: string, ord: number, controls: ControlState[] }} FormSnapshot
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How a form is found again after a re-render: its `id`, else its bound
|
|
45
|
+
* action identity, else its `action` attribute, plus its position among the
|
|
46
|
+
* forms sharing that key.
|
|
47
|
+
*
|
|
48
|
+
* @param {Element} form
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function formKey(form) {
|
|
52
|
+
if (form.id) return `id:${form.id}`;
|
|
53
|
+
const field = form.querySelector(`input[name="${FORM_ACTION_FIELD}"]`);
|
|
54
|
+
if (field) return `act:${field.getAttribute('value') || ''}`;
|
|
55
|
+
return `url:${form.getAttribute('action') || ''}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {Element} form @param {string} key */
|
|
59
|
+
function ordinalOf(form, key) {
|
|
60
|
+
let i = 0;
|
|
61
|
+
for (const f of document.querySelectorAll('form')) {
|
|
62
|
+
if (f === form) return i;
|
|
63
|
+
if (formKey(f) === key) i++;
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {Element} el */
|
|
69
|
+
function kindOf(el) {
|
|
70
|
+
const tag = el.localName;
|
|
71
|
+
if (tag === 'textarea') return 'text';
|
|
72
|
+
if (tag === 'select') return 'select';
|
|
73
|
+
if (tag !== 'input') return null;
|
|
74
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase();
|
|
75
|
+
if (SKIP_TYPES.has(type)) return null;
|
|
76
|
+
return type === 'checkbox' || type === 'radio' ? 'check' : 'text';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The controls of `form`, in document order, each with its per-name ordinal.
|
|
81
|
+
* Uses `form.elements` so a control associated by `form="id"` outside the
|
|
82
|
+
* form element is included.
|
|
83
|
+
*
|
|
84
|
+
* @param {HTMLFormElement} form
|
|
85
|
+
* @returns {Array<{ el: any, name: string, kind: 'text' | 'check' | 'select', n: number }>}
|
|
86
|
+
*/
|
|
87
|
+
function controlsOf(form) {
|
|
88
|
+
/** @type {Map<string, number>} */
|
|
89
|
+
const seen = new Map();
|
|
90
|
+
const out = [];
|
|
91
|
+
const list = form.elements ? [...form.elements] : [...form.querySelectorAll('input, textarea, select')];
|
|
92
|
+
for (const el of list) {
|
|
93
|
+
const name = el.getAttribute && el.getAttribute('name');
|
|
94
|
+
if (!name || name === FORM_ACTION_FIELD) continue;
|
|
95
|
+
const kind = kindOf(el);
|
|
96
|
+
if (!kind) continue;
|
|
97
|
+
// Radios and checkboxes are told apart by their value, so the ordinal
|
|
98
|
+
// counts within name + value; text controls count within the name.
|
|
99
|
+
const key = kind === 'check' ? `${name}\u0000${el.getAttribute('value') ?? 'on'}` : name;
|
|
100
|
+
const n = seen.get(key) || 0;
|
|
101
|
+
seen.set(key, n + 1);
|
|
102
|
+
out.push({ el, name, kind: /** @type any */ (kind), n });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {any} el @param {'text' | 'check' | 'select'} kind */
|
|
108
|
+
function readState(el, kind) {
|
|
109
|
+
if (kind === 'check') return { def: String(!!el.defaultChecked), cur: String(!!el.checked) };
|
|
110
|
+
if (kind === 'select') {
|
|
111
|
+
const opts = [...el.options];
|
|
112
|
+
return {
|
|
113
|
+
def: JSON.stringify(opts.map((o) => o.defaultSelected)),
|
|
114
|
+
cur: JSON.stringify(opts.filter((o) => o.selected).map((o) => o.value)),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { def: el.defaultValue ?? '', cur: el.value ?? '' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Snapshot a form's controls, or null when the form opts out.
|
|
122
|
+
*
|
|
123
|
+
* @param {HTMLFormElement | null | undefined} form
|
|
124
|
+
* @returns {FormSnapshot | null}
|
|
125
|
+
*/
|
|
126
|
+
export function captureFormState(form) {
|
|
127
|
+
if (!form || form.getAttribute('data-preserve-values') === 'false') return null;
|
|
128
|
+
const key = formKey(form);
|
|
129
|
+
const controls = controlsOf(form).map(({ el, name, kind, n }) => ({
|
|
130
|
+
name, kind, n,
|
|
131
|
+
...(kind === 'check' ? { value: el.getAttribute('value') ?? 'on' } : {}),
|
|
132
|
+
...readState(el, kind),
|
|
133
|
+
}));
|
|
134
|
+
if (!controls.length) return null;
|
|
135
|
+
return { form, key, ord: ordinalOf(form, key), controls };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Find the snapshot's form in the current document.
|
|
140
|
+
*
|
|
141
|
+
* @param {FormSnapshot} snap
|
|
142
|
+
* @returns {HTMLFormElement | null}
|
|
143
|
+
*/
|
|
144
|
+
function findForm(snap) {
|
|
145
|
+
if (snap.form.isConnected) return snap.form;
|
|
146
|
+
let i = 0;
|
|
147
|
+
for (const f of document.querySelectorAll('form')) {
|
|
148
|
+
if (formKey(f) !== snap.key) continue;
|
|
149
|
+
if (i === snap.ord) return /** @type any */ (f);
|
|
150
|
+
i++;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Write the snapshot's typed state back into the re-rendered form, control by
|
|
157
|
+
* control, wherever the server left the control's default unchanged.
|
|
158
|
+
*
|
|
159
|
+
* @param {FormSnapshot | null} snap
|
|
160
|
+
* @returns {number} how many controls were restored (for tests)
|
|
161
|
+
*/
|
|
162
|
+
export function restoreFormState(snap) {
|
|
163
|
+
if (!snap) return 0;
|
|
164
|
+
const form = findForm(snap);
|
|
165
|
+
if (!form) return 0;
|
|
166
|
+
const byKey = new Map();
|
|
167
|
+
for (const c of controlsOf(form)) {
|
|
168
|
+
const k = c.kind === 'check'
|
|
169
|
+
? `${c.name}\u0000${c.el.getAttribute('value') ?? 'on'}\u0000${c.n}`
|
|
170
|
+
: `${c.name}\u0000${c.n}`;
|
|
171
|
+
byKey.set(k, c);
|
|
172
|
+
}
|
|
173
|
+
let restored = 0;
|
|
174
|
+
for (const s of snap.controls) {
|
|
175
|
+
const k = s.kind === 'check' ? `${s.name}\u0000${s.value}\u0000${s.n}` : `${s.name}\u0000${s.n}`;
|
|
176
|
+
const c = byKey.get(k);
|
|
177
|
+
if (!c || c.kind !== s.kind) continue;
|
|
178
|
+
const now = readState(c.el, c.kind);
|
|
179
|
+
// The server rendered this control differently on purpose, so it wins.
|
|
180
|
+
if (now.def !== s.def) continue;
|
|
181
|
+
if (now.cur === s.cur) continue;
|
|
182
|
+
if (s.kind === 'check') c.el.checked = s.cur === 'true';
|
|
183
|
+
else if (s.kind === 'select') {
|
|
184
|
+
const want = new Set(JSON.parse(s.cur));
|
|
185
|
+
for (const o of c.el.options) o.selected = want.has(o.value);
|
|
186
|
+
} else c.el.value = s.cur;
|
|
187
|
+
restored++;
|
|
188
|
+
}
|
|
189
|
+
return restored;
|
|
190
|
+
}
|
|
@@ -13,6 +13,7 @@ import { parseHTML } from './dom-parse.js';
|
|
|
13
13
|
import { onClick, onPopState, onSubmit } from './events.js';
|
|
14
14
|
import { fetchAndApply } from './fetch-apply.js';
|
|
15
15
|
import { clearFormBusy, markFormBusy } from './frames.js';
|
|
16
|
+
import { captureFormState, restoreFormState } from './form-state.js';
|
|
16
17
|
import { clearPrefetchHover, clearPrefetchRefused, clearPrefetchViewTimers, onPrefetchIntent, onPrefetchOut, prefetchCache, prefetchClearAll, prefetchEvict, refreshPrefetchObservers, teardownPrefetchViewObserver } from './prefetch.js';
|
|
17
18
|
// `restoreGeneration` is imported READ-ONLY: the deferred restore captures it
|
|
18
19
|
// and re-compares after the frame, so it must be the live binding. Writes go
|
|
@@ -869,6 +870,10 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
869
870
|
// (markFormBusy/clearFormBusy) keeps a superseded submit's teardown from
|
|
870
871
|
// clearing the busy state a newer submit set.
|
|
871
872
|
const busyForm = form ? markFormBusy(form, myToken, url.href) : null;
|
|
873
|
+
// #1581: what the person typed, taken before the request leaves, so a failed
|
|
874
|
+
// submission's in-place re-render can hand it back (see form-state.js). A
|
|
875
|
+
// safe-method submission is a search, not a write, and is left alone.
|
|
876
|
+
const typed = !isSafe ? captureFormState(form) : null;
|
|
872
877
|
let outcomeOk = false;
|
|
873
878
|
try {
|
|
874
879
|
const outcome = await fetchAndApply(
|
|
@@ -884,6 +889,13 @@ export async function performSubmission(href, method, body, frameId, form, opts)
|
|
|
884
889
|
{ preserveScroll: !!(opts && opts.preserveScroll) },
|
|
885
890
|
);
|
|
886
891
|
outcomeOk = !!(outcome && outcome.ok);
|
|
892
|
+
// A non-2xx response applied in place is the failed-validation re-render.
|
|
893
|
+
// Restore after the swap COMMITS: under an async view transition the DOM
|
|
894
|
+
// changes a frame later than `fetchAndApply` returns.
|
|
895
|
+
if (typed && outcome && outcome.applied && !outcome.ok && myToken === currentNavigationToken) {
|
|
896
|
+
await _swapCommit;
|
|
897
|
+
if (myToken === currentNavigationToken) restoreFormState(typed);
|
|
898
|
+
}
|
|
887
899
|
// Mutating submissions invalidate cached versions of other URLs -
|
|
888
900
|
// do this *after* the response applies so the new page itself is
|
|
889
901
|
// snapshotted on the next nav, not pre-emptively wiped. Clear the
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { SNAPSHOT_CAP } from './constants.js';
|
|
10
10
|
|
|
11
11
|
/** @typedef {{ html: string, scrollX: number, scrollY: number, scrollHeight: number }} Snapshot */
|
|
12
|
-
/** @type {Map<string, Snapshot
|
|
12
|
+
/** @type {Map<string, Snapshot>} */
|
|
13
13
|
export const snapshotCache = new Map();
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -59,9 +59,7 @@ export function snapshotCurrent(url) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Look up a cached snapshot by URL
|
|
63
|
-
* null. Tolerates legacy string entries (e.g. from test fixtures that
|
|
64
|
-
* `_snapshotCache.set('/x', 'snap')`).
|
|
62
|
+
* Look up a cached snapshot by URL, or null on a miss.
|
|
65
63
|
*
|
|
66
64
|
* @param {string} url
|
|
67
65
|
* @returns {Snapshot | null}
|
|
@@ -73,14 +71,6 @@ export function snapshotGet(url) {
|
|
|
73
71
|
// Move-to-front.
|
|
74
72
|
snapshotCache.delete(key);
|
|
75
73
|
snapshotCache.set(key, v);
|
|
76
|
-
// A legacy string entry carries no offsets and no height. `scrollHeight: 0`
|
|
77
|
-
// is stated rather than left undefined so the returned object satisfies the
|
|
78
|
-
// `Snapshot` typedef, and because `reserveRestoredHeight` treats a
|
|
79
|
-
// non-positive height as "nothing to reserve" and no-ops: there is no
|
|
80
|
-
// recorded height to hold, so reserving is not merely skippable, it is
|
|
81
|
-
// meaningless. Such an entry restores with no reservation, which is the
|
|
82
|
-
// pre-#1428 behaviour and correct for a snapshot that never recorded one.
|
|
83
|
-
if (typeof v === 'string') return { html: v, scrollX: 0, scrollY: 0, scrollHeight: 0 };
|
|
84
74
|
return v;
|
|
85
75
|
}
|
|
86
76
|
|
package/src/routes.d.ts
CHANGED
|
@@ -124,6 +124,8 @@ export interface PageProps<R extends string = string> {
|
|
|
124
124
|
* Present ONLY on the re-render after a failed page `action` submission
|
|
125
125
|
* (#244). `undefined` on a normal GET render. Read `actionData.fieldErrors`
|
|
126
126
|
* / `actionData.values` to surface validation errors and repopulate inputs.
|
|
127
|
+
* On a failure `values` carries every submitted text field, with the action's
|
|
128
|
+
* own `values` on top (#1581).
|
|
127
129
|
*/
|
|
128
130
|
actionData?: unknown;
|
|
129
131
|
}
|
package/src/slot.js
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
*
|
|
75
75
|
* SSR. This module is import-safe in Node (DOM access is guarded on
|
|
76
76
|
* `typeof HTMLSlotElement !== 'undefined'`). Server-side slot substitution
|
|
77
|
-
* lives in render-server.js (
|
|
77
|
+
* lives in render-server/expand.js (`finishSlot`); slot.js drives the client runtime.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import { escapeAttr } from '../escape.js';
|
|
2
|
-
import {
|
|
3
|
-
endOfComment, escapeRegex, findClosingTagInString, inertAt, inertRanges, isVoidElement,
|
|
4
|
-
} from './html-scan.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* SSR light-DOM slot projection: partition a component's authored children by
|
|
8
|
-
* their `slot=""` attribute, then substitute each `<slot>` in the rendered
|
|
9
|
-
* template with the projected children or the authored fallback.
|
|
10
|
-
*
|
|
11
|
-
* String-level throughout, so it depends on `html-scan.js` and nothing else in
|
|
12
|
-
* this directory.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Extract the `slot` attribute value from an attribute string. Returns
|
|
17
|
-
* null when the attribute is absent.
|
|
18
|
-
*
|
|
19
|
-
* @param {string} attrsRaw
|
|
20
|
-
* @returns {string | null}
|
|
21
|
-
*/
|
|
22
|
-
function extractSlotAttr(attrsRaw) {
|
|
23
|
-
const m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
24
|
-
if (!m) return null;
|
|
25
|
-
const value = m[1] ?? m[2] ?? m[3] ?? '';
|
|
26
|
-
// Per shadow DOM spec, slot="" (empty) and missing slot attribute both
|
|
27
|
-
// route to the default slot. `default` is the framework's reserved alias
|
|
28
|
-
// for it (#1015: the client record normalizes it identically, so both
|
|
29
|
-
// sides agree end to end).
|
|
30
|
-
return value === '' || value === 'default' ? null : value;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Partition authored inner HTML by each top-level child's `slot=""`
|
|
35
|
-
* attribute. Text nodes, comment nodes, and elements without `slot=""`
|
|
36
|
-
* all route to the default-slot key (null).
|
|
37
|
-
*
|
|
38
|
-
* Returns a Map keyed by slot name (null for default) whose values are
|
|
39
|
-
* the concatenated HTML strings for that slot in source order.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} html
|
|
42
|
-
* @returns {Map<string|null, string>}
|
|
43
|
-
*/
|
|
44
|
-
export function partitionAuthoredBySlot(html) {
|
|
45
|
-
/** @type {Map<string|null, string>} */
|
|
46
|
-
const groups = new Map();
|
|
47
|
-
let defaultBuf = '';
|
|
48
|
-
let cursor = 0;
|
|
49
|
-
while (cursor < html.length) {
|
|
50
|
-
const lt = html.indexOf('<', cursor);
|
|
51
|
-
if (lt === -1) {
|
|
52
|
-
defaultBuf += html.slice(cursor);
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
55
|
-
if (lt > cursor) defaultBuf += html.slice(cursor, lt);
|
|
56
|
-
const rest = html.slice(lt);
|
|
57
|
-
if (rest.startsWith('<!--')) {
|
|
58
|
-
// Find the comment's end the same way inertRanges does, rather than with
|
|
59
|
-
// a bare `indexOf('-->')`. The two helpers both decide where a comment
|
|
60
|
-
// stops, so a bare search makes them DISAGREE on the spec short forms
|
|
61
|
-
// (`--!>`, `<!-->`, `<!--->`): this one would run past the real end and
|
|
62
|
-
// swallow the slotted children that follow, silently routing a
|
|
63
|
-
// `slot="head"` child into the default slot.
|
|
64
|
-
const commentEnd = endOfComment(html, lt);
|
|
65
|
-
if (commentEnd === -1) {
|
|
66
|
-
defaultBuf += rest;
|
|
67
|
-
cursor = html.length;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
defaultBuf += html.slice(lt, commentEnd);
|
|
71
|
-
cursor = commentEnd;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (rest.startsWith('<!') || rest.startsWith('</')) {
|
|
75
|
-
const end = html.indexOf('>', lt);
|
|
76
|
-
if (end === -1) {
|
|
77
|
-
defaultBuf += rest;
|
|
78
|
-
cursor = html.length;
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
defaultBuf += html.slice(lt, end + 1);
|
|
82
|
-
cursor = end + 1;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const tagMatch = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/.exec(rest);
|
|
86
|
-
if (!tagMatch) {
|
|
87
|
-
defaultBuf += '<';
|
|
88
|
-
cursor = lt + 1;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const [tagFull, tagName, attrsRaw, selfCloseSlash] = tagMatch;
|
|
92
|
-
const lower = tagName.toLowerCase();
|
|
93
|
-
const isSelfClose = !!selfCloseSlash || isVoidElement(lower);
|
|
94
|
-
const slotAttr = extractSlotAttr(attrsRaw);
|
|
95
|
-
let elemEnd;
|
|
96
|
-
if (isSelfClose) {
|
|
97
|
-
elemEnd = lt + tagFull.length;
|
|
98
|
-
} else {
|
|
99
|
-
const innerStart = lt + tagFull.length;
|
|
100
|
-
const closeIdx = findClosingTagInString(html, innerStart, lower);
|
|
101
|
-
if (closeIdx === -1) {
|
|
102
|
-
// Unclosed element. Take to end of html.
|
|
103
|
-
const elementHTML = html.slice(lt);
|
|
104
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
105
|
-
else defaultBuf += elementHTML;
|
|
106
|
-
cursor = html.length;
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const closeRe = new RegExp(`</${escapeRegex(lower)}\\s*>`, 'i');
|
|
110
|
-
const tail = html.slice(closeIdx);
|
|
111
|
-
const closeMatch = closeRe.exec(tail);
|
|
112
|
-
const closeLen = closeMatch ? closeMatch[0].length : `</${lower}>`.length;
|
|
113
|
-
elemEnd = closeIdx + closeLen;
|
|
114
|
-
}
|
|
115
|
-
const elementHTML = html.slice(lt, elemEnd);
|
|
116
|
-
if (slotAttr !== null) appendStringToMap(groups, slotAttr, elementHTML);
|
|
117
|
-
else defaultBuf += elementHTML;
|
|
118
|
-
cursor = elemEnd;
|
|
119
|
-
}
|
|
120
|
-
if (defaultBuf.length > 0) groups.set(null, defaultBuf);
|
|
121
|
-
return groups;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Append a string to a Map<K, string>, concatenating if the key exists. */
|
|
125
|
-
function appendStringToMap(map, key, value) {
|
|
126
|
-
const existing = map.get(key);
|
|
127
|
-
if (existing !== undefined) map.set(key, existing + value);
|
|
128
|
-
else map.set(key, value);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Substitute every `<slot>` tag in `rendered` with a framework-marked
|
|
133
|
-
* `<slot data-webjs-light data-projection="actual|fallback">` element
|
|
134
|
-
* carrying either the projected children for that slot (from
|
|
135
|
-
* `partitioned`) or the slot's authored fallback content. Multiple
|
|
136
|
-
* slots with the same name follow the first-wins rule per spec; later
|
|
137
|
-
* same-named slots fall back regardless of available projection.
|
|
138
|
-
*
|
|
139
|
-
* The `ownerTag` (the tag of the component whose template rendered these
|
|
140
|
-
* slots) is emitted as `data-wj-slot-owner` so the client resolves template
|
|
141
|
-
* ownership on hydration the same way the client renderer stamps SLOT_OWNER,
|
|
142
|
-
* which is what makes a FORWARDED slot (rendered by this component but nested
|
|
143
|
-
* inside a child) route to this component and not the child (#1023).
|
|
144
|
-
*
|
|
145
|
-
* @param {string} rendered
|
|
146
|
-
* @param {Map<string|null, string>} partitioned
|
|
147
|
-
* @param {string} ownerTag
|
|
148
|
-
* @returns {string}
|
|
149
|
-
*/
|
|
150
|
-
export function substituteSlotsInRender(rendered, partitioned, ownerTag) {
|
|
151
|
-
const ownerAttr = ownerTag ? ` data-wj-slot-owner="${escapeAttr(ownerTag)}"` : '';
|
|
152
|
-
/** @type {Set<string|null>} */
|
|
153
|
-
const consumedNames = new Set();
|
|
154
|
-
let result = '';
|
|
155
|
-
let cursor = 0;
|
|
156
|
-
const slotRe = /<slot((?:"[^"]*"|'[^']*'|[^>])*?)(\/?)>/gi;
|
|
157
|
-
// A `<slot>` written inside a comment is documentation, not a slot (#1128).
|
|
158
|
-
// Substituting one is worse here than in the element walk: a commented slot
|
|
159
|
-
// has no `</slot>`, so the fallback scan below swallows the rest of the
|
|
160
|
-
// template, the component's REAL slot is never substituted, and the authored
|
|
161
|
-
// children are dropped from the page entirely.
|
|
162
|
-
const inert = inertRanges(rendered);
|
|
163
|
-
const isInert = inertAt(inert);
|
|
164
|
-
let m;
|
|
165
|
-
while ((m = slotRe.exec(rendered)) !== null) {
|
|
166
|
-
if (isInert(m.index)) continue;
|
|
167
|
-
result += rendered.slice(cursor, m.index);
|
|
168
|
-
const [fullOpen, attrsRaw, selfCloseSlash] = m;
|
|
169
|
-
const isSelfClose = !!selfCloseSlash;
|
|
170
|
-
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
|
|
171
|
-
const name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
|
|
172
|
-
// Strip the `name` attribute from the carried-through attribute
|
|
173
|
-
// string so we can re-add it (with escaping) on the framework slot.
|
|
174
|
-
const otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
|
|
175
|
-
let fallback = '';
|
|
176
|
-
let totalEnd;
|
|
177
|
-
if (isSelfClose) {
|
|
178
|
-
totalEnd = m.index + fullOpen.length;
|
|
179
|
-
} else {
|
|
180
|
-
const innerStart = m.index + fullOpen.length;
|
|
181
|
-
const closeIdx = findClosingTagInString(rendered, innerStart, 'slot', inert);
|
|
182
|
-
if (closeIdx === -1) {
|
|
183
|
-
fallback = rendered.slice(innerStart);
|
|
184
|
-
totalEnd = rendered.length;
|
|
185
|
-
} else {
|
|
186
|
-
fallback = rendered.slice(innerStart, closeIdx);
|
|
187
|
-
const closeRe = /<\/slot\s*>/i;
|
|
188
|
-
const tail = rendered.slice(closeIdx);
|
|
189
|
-
const closeMatch = closeRe.exec(tail);
|
|
190
|
-
const closeLen = closeMatch ? closeMatch[0].length : '</slot>'.length;
|
|
191
|
-
totalEnd = closeIdx + closeLen;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// `default` and `''` are the reserved aliases for the default slot
|
|
195
|
-
// (#1015), matching the client's keyOfName exactly: the LOOKUP key
|
|
196
|
-
// normalizes, while the emitted name attribute stays as authored so the
|
|
197
|
-
// output bytes are unchanged for every other app.
|
|
198
|
-
const slotKey = name === 'default' || name === '' ? null : name;
|
|
199
|
-
const projected = partitioned.get(slotKey);
|
|
200
|
-
const nameAttr = name !== null ? ` name="${escapeAttr(name)}"` : '';
|
|
201
|
-
const extraAttrs = otherAttrs ? ` ${otherAttrs}` : '';
|
|
202
|
-
if (projected !== undefined && !consumedNames.has(slotKey)) {
|
|
203
|
-
consumedNames.add(slotKey);
|
|
204
|
-
result += `<slot data-webjs-light data-projection="actual"${ownerAttr}${nameAttr}${extraAttrs}>${projected}</slot>`;
|
|
205
|
-
} else {
|
|
206
|
-
result += `<slot data-webjs-light data-projection="fallback"${ownerAttr}${nameAttr}${extraAttrs}>${fallback}</slot>`;
|
|
207
|
-
}
|
|
208
|
-
cursor = totalEnd;
|
|
209
|
-
slotRe.lastIndex = totalEnd;
|
|
210
|
-
}
|
|
211
|
-
result += rendered.slice(cursor);
|
|
212
|
-
return result;
|
|
213
|
-
}
|