@webjsdev/core 0.7.65 → 0.8.0
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 +19 -19
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +6 -6
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +5 -1
- package/index.d.ts +5 -1
- package/index.js +5 -1
- package/package.json +1 -1
- package/src/attribute-directives.d.ts +6 -0
- package/src/attribute-directives.js +49 -0
- package/src/attribute-reader.js +17 -3
- package/src/binding-prefixes.js +10 -41
- package/src/component/lifecycle.js +177 -59
- package/src/component/reactive.js +5 -4
- package/src/component.d.ts +11 -5
- package/src/directives.d.ts +2 -0
- package/src/directives.js +2 -0
- package/src/html.d.ts +4 -0
- package/src/html.js +22 -1
- package/src/render-client/adopt-slots.js +245 -0
- package/src/render-client/parts.js +51 -17
- package/src/render-client/reconciler.js +28 -12
- package/src/render-client/template-compiler.js +19 -5
- package/src/render-client.d.ts +2 -1
- package/src/render-server/compile.js +16 -2
- package/src/render-server/expand.js +39 -12
- package/src/render-server/served-attrs.js +79 -0
- package/src/render-server/walk.js +42 -2
- package/src/router-client/anchors.js +3 -1
- package/src/router-client/boundaries.js +15 -1
- package/src/router-client/constants.js +30 -0
- package/src/router-client/fetch-apply.js +21 -0
- package/src/router-client/frames.js +6 -3
- package/src/router-client/head-merge.js +58 -10
- package/src/router-client/navigator.js +4 -0
- package/src/router-client/owned-styles.js +372 -0
- package/src/router-client/prefetch.js +3 -0
- package/src/router-client/stylesheets.js +191 -0
- package/src/router-client/swap.js +49 -9
- package/src/sentinels.d.ts +2 -0
- package/src/sentinels.js +4 -0
- package/src/webjs-config.d.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nothing, noChange } from '../sentinels.js';
|
|
1
2
|
import { isTemplate, MARKER } from '../html.js';
|
|
2
3
|
import {
|
|
3
4
|
reconcileFormAction, isBoundFormAction, ABSENT, assertSubmitterHasNoName, assertSubmitterType,
|
|
@@ -10,8 +11,9 @@ import { isLive } from '../directives.js';
|
|
|
10
11
|
import { attrHoleValue } from '../binding-prefixes.js';
|
|
11
12
|
import { RENDERING, SLOT_OWNER, SLOT_STATE, drainRendererBackstop, rescueAssignedNodes } from '../slot.js';
|
|
12
13
|
import { compile, submitterActionBindings, INSTANCE } from './template-compiler.js';
|
|
14
|
+
import { commitAdoption, planSlotAdoption, retargetParts } from './adopt-slots.js';
|
|
13
15
|
import {
|
|
14
|
-
applyPart, bindPart, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
|
|
16
|
+
applyPart, bindPart, currentRenderHost, setCurrentRenderHost, currentRenderRoot, findSlotHost, setCurrentRenderRoot, updateInstance,
|
|
15
17
|
} from './parts.js';
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -19,8 +21,10 @@ import {
|
|
|
19
21
|
*
|
|
20
22
|
* @param {unknown} value
|
|
21
23
|
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
24
|
+
* @param {{host?: object}} [options]
|
|
22
25
|
*/
|
|
23
|
-
export function render(value, container) {
|
|
26
|
+
export function render(value, container, options = {}) {
|
|
27
|
+
if (value === noChange) return;
|
|
24
28
|
const host = /** @type any */ (container);
|
|
25
29
|
// Open the renderer-write window for the whole commit: every host-receiver
|
|
26
30
|
// write below (and in createInstance / updateInstance / clearInstance / all
|
|
@@ -29,6 +33,8 @@ export function render(value, container) {
|
|
|
29
33
|
// between a renderer commit and an author write.
|
|
30
34
|
const prevRendering = host[RENDERING];
|
|
31
35
|
host[RENDERING] = true;
|
|
36
|
+
const prevHost = currentRenderHost;
|
|
37
|
+
setCurrentRenderHost(options.host);
|
|
32
38
|
const prevRenderRoot = currentRenderRoot;
|
|
33
39
|
setCurrentRenderRoot(container);
|
|
34
40
|
try {
|
|
@@ -44,12 +50,12 @@ export function render(value, container) {
|
|
|
44
50
|
|
|
45
51
|
// Light DOM hydration: if container has SSR content (marked by
|
|
46
52
|
// <!--webjs-hydrate-->), remove the marker and proceed with normal
|
|
47
|
-
// rendering. The content
|
|
48
|
-
//
|
|
53
|
+
// rendering. The content is replaced with identical output, so nothing
|
|
54
|
+
// flashes, except that the SSR slots holding projected children stay in
|
|
55
|
+
// place (createInstance, #1666), so those children never leave the page.
|
|
49
56
|
const firstChild = container.firstChild;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
57
|
+
const hydrating = !!firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate';
|
|
58
|
+
if (hydrating) firstChild.remove();
|
|
53
59
|
|
|
54
60
|
// Pre-set the symbol to an explicit null BEFORE the commit,
|
|
55
61
|
// UNCONDITIONALLY: if createInstance throws after its replaceChildren
|
|
@@ -59,7 +65,7 @@ export function render(value, container) {
|
|
|
59
65
|
// instance on the template-swap path (whose bookends would misclassify
|
|
60
66
|
// the half-committed new roots as unowned and fold them).
|
|
61
67
|
host[INSTANCE] = null;
|
|
62
|
-
const inst = createInstance(tr, container);
|
|
68
|
+
const inst = createInstance(tr, container, hydrating);
|
|
63
69
|
host[INSTANCE] = inst;
|
|
64
70
|
return;
|
|
65
71
|
}
|
|
@@ -68,7 +74,7 @@ export function render(value, container) {
|
|
|
68
74
|
if (prev) clearInstance(prev, container);
|
|
69
75
|
host[INSTANCE] = null;
|
|
70
76
|
container.replaceChildren();
|
|
71
|
-
if (value == null || value ===
|
|
77
|
+
if (value == null || value === nothing) return;
|
|
72
78
|
if (Array.isArray(value)) {
|
|
73
79
|
for (const v of value) {
|
|
74
80
|
const text = document.createTextNode(String(v ?? ''));
|
|
@@ -79,6 +85,7 @@ export function render(value, container) {
|
|
|
79
85
|
container.appendChild(document.createTextNode(String(value)));
|
|
80
86
|
} finally {
|
|
81
87
|
setCurrentRenderRoot(prevRenderRoot);
|
|
88
|
+
setCurrentRenderHost(prevHost);
|
|
82
89
|
host[RENDERING] = prevRendering;
|
|
83
90
|
// Outermost window closing: drain this commit's childList records off the
|
|
84
91
|
// slot backstop (drainRendererBackstop processes them with a renderer-output
|
|
@@ -307,7 +314,7 @@ function resolveHoleValue(v) {
|
|
|
307
314
|
* @param {import('../html.js').TemplateResult} tr
|
|
308
315
|
* @param {Element | DocumentFragment | ShadowRoot} container
|
|
309
316
|
*/
|
|
310
|
-
function createInstance(tr, container) {
|
|
317
|
+
function createInstance(tr, container, hydrating = false) {
|
|
311
318
|
const { templateEl, parts, formActions } = compile(tr);
|
|
312
319
|
const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
|
|
313
320
|
// Bookend markers bound the instance so we can tear it down cleanly.
|
|
@@ -315,6 +322,11 @@ function createInstance(tr, container) {
|
|
|
315
322
|
const endNode = document.createComment(`${MARKER}e`);
|
|
316
323
|
|
|
317
324
|
const bound = parts.map((p) => bindPart(p, frag));
|
|
325
|
+
// Hydration keeps the SSR slots that hold projected children, and their
|
|
326
|
+
// ancestors, in the document (#1666): the parts targeting them are pointed at
|
|
327
|
+
// the live elements now, before any applies.
|
|
328
|
+
const adoption = hydrating ? planSlotAdoption(container, frag, bound) : null;
|
|
329
|
+
if (adoption) retargetParts(adoption, bound);
|
|
318
330
|
const lastValues = [];
|
|
319
331
|
for (let i = 0; i < tr.values.length; i++) {
|
|
320
332
|
applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActions);
|
|
@@ -322,7 +334,11 @@ function createInstance(tr, container) {
|
|
|
322
334
|
}
|
|
323
335
|
reconcileFormActions(formActions, bound, tr.values);
|
|
324
336
|
|
|
325
|
-
|
|
337
|
+
if (adoption) {
|
|
338
|
+
commitAdoption(adoption, /** @type {Element} */ (container), [startNode, ...frag.childNodes, endNode]);
|
|
339
|
+
} else {
|
|
340
|
+
/** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
|
|
341
|
+
}
|
|
326
342
|
|
|
327
343
|
// Slot parts have no value-hole to drive applyPart from the loop above.
|
|
328
344
|
// Apply them once now that the fragment is inserted into the live
|
|
@@ -363,7 +379,7 @@ function clearInstance(inst, container) {
|
|
|
363
379
|
// slot parts so they survive teardown of a collapsing conditional
|
|
364
380
|
// fragment.
|
|
365
381
|
for (const p of inst.bound) {
|
|
366
|
-
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
|
|
382
|
+
if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher, p.handler);
|
|
367
383
|
if (p.kind === 'element') {
|
|
368
384
|
// Guarded for the same reason as the sibling in `disposeInstance`, and
|
|
369
385
|
// the stakes are higher here: this is the container-level teardown
|
|
@@ -31,10 +31,10 @@ export const INSTANCE = Symbol.for('webjs.instance');
|
|
|
31
31
|
* }} TemplateInstance
|
|
32
32
|
*
|
|
33
33
|
* @typedef {
|
|
34
|
-
* | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
|
|
34
|
+
* | { kind: 'child', marker: Comment, __eventHost?: object, child?: TemplateInstance | ChildNode[] }
|
|
35
35
|
* | { kind: 'attr', el: Element, name: string }
|
|
36
36
|
* | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
|
|
37
|
-
* | { kind: 'event', el: Element, name: string, handler: (
|
|
37
|
+
* | { kind: 'event', el: Element, name: string, handler: (EventListenerOrEventListenerObject & AddEventListenerOptions) | null, eventHost?: object, dispatcher: (e: Event) => void }
|
|
38
38
|
* | { kind: 'prop', el: Element, name: string }
|
|
39
39
|
* | { kind: 'bool', el: Element, name: string }
|
|
40
40
|
* | { kind: 'element', el: Element, lastTarget?: any }
|
|
@@ -169,10 +169,14 @@ export function compile(tr) {
|
|
|
169
169
|
const eq = sLast.indexOf(attrQuote);
|
|
170
170
|
statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
|
|
171
171
|
|
|
172
|
+
const specialized = isBindingPrefix(mixedAttr.name[0]);
|
|
173
|
+
if (specialized && (group.length !== 1 || statics.some((piece) => piece !== ''))) {
|
|
174
|
+
throw new Error('Event, property and boolean bindings require exactly one expression with no surrounding text.');
|
|
175
|
+
}
|
|
172
176
|
parts[idx0] = {
|
|
173
|
-
kind: 'attr-mixed',
|
|
177
|
+
kind: specialized ? BINDING_PREFIXES[mixedAttr.name[0]] : 'attr-mixed',
|
|
174
178
|
path: [],
|
|
175
|
-
name: mixedAttr.name,
|
|
179
|
+
name: specialized ? mixedAttr.name.slice(1) : mixedAttr.name,
|
|
176
180
|
statics,
|
|
177
181
|
group,
|
|
178
182
|
};
|
|
@@ -262,7 +266,17 @@ export function compile(tr) {
|
|
|
262
266
|
}
|
|
263
267
|
|
|
264
268
|
const templateEl = document.createElement('template');
|
|
265
|
-
|
|
269
|
+
if (tr.svg) {
|
|
270
|
+
// An svg`` fragment is parsed inside an <svg> wrapper so its elements
|
|
271
|
+
// land in the SVG namespace, then the wrapper is unwrapped BEFORE the
|
|
272
|
+
// slot and path walks, so every recorded path is relative to the
|
|
273
|
+
// fragment's own root, as it is for an html`` template (lit does the same).
|
|
274
|
+
templateEl.innerHTML = `<svg>${html}</svg>`;
|
|
275
|
+
const wrapper = /** @type {Element} */ (templateEl.content.firstChild);
|
|
276
|
+
wrapper.replaceWith(...wrapper.childNodes);
|
|
277
|
+
} else {
|
|
278
|
+
templateEl.innerHTML = html;
|
|
279
|
+
}
|
|
266
280
|
|
|
267
281
|
// Mark every <slot> in the template for framework projection and
|
|
268
282
|
// register a SLOT part for each so the slot-apply step can find them on
|
package/src/render-client.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface RenderOptions { host?: object; }
|
|
2
|
+
export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot, options?: RenderOptions): void;
|
|
@@ -181,8 +181,10 @@ function tokenize(strings) {
|
|
|
181
181
|
state = 'text';
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
+
let bindingQuote = '';
|
|
184
185
|
for (let i = 0; i < strings.length; i++) {
|
|
185
|
-
const s = strings[i];
|
|
186
|
+
const s = bindingQuote ? strings[i].slice(1) : strings[i];
|
|
187
|
+
bindingQuote = '';
|
|
186
188
|
for (let j = 0; j < s.length; j++) {
|
|
187
189
|
const c = s[j];
|
|
188
190
|
switch (state) {
|
|
@@ -393,9 +395,21 @@ function tokenize(strings) {
|
|
|
393
395
|
});
|
|
394
396
|
state = 'in-tag';
|
|
395
397
|
attrName = '';
|
|
398
|
+
} else if (state === 'attr-quoted' && BINDING_PREFIXES[attrName[0]] !== undefined) {
|
|
399
|
+
const lead = chunk.slice(attrStart);
|
|
400
|
+
if (!lead.endsWith('=' + attrQuote) || !strings[i + 1].startsWith(attrQuote)) {
|
|
401
|
+
throw new Error('Event, property and boolean bindings require exactly one expression with no surrounding text.');
|
|
402
|
+
}
|
|
403
|
+
chunk = chunk.slice(0, attrStart);
|
|
404
|
+
flush();
|
|
405
|
+
ops.push({ t: HOLE, k: ATTR, kind: BINDING_PREFIXES[attrName[0]], name: attrName.slice(1), tag: currentTag, lead: lead.slice(0, -1) });
|
|
406
|
+
bindingQuote = attrQuote;
|
|
407
|
+
state = 'in-tag';
|
|
408
|
+
attrName = '';
|
|
396
409
|
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
|
|
410
|
+
const prefix = chunk.slice(attrStart);
|
|
397
411
|
flush();
|
|
398
|
-
ops.push({ t: HOLE, k: VALUE, name: attrName, tag: currentTag });
|
|
412
|
+
ops.push({ t: HOLE, k: VALUE, name: attrName, tag: currentTag, quote: state === 'attr-quoted' ? attrQuote : '', prefix });
|
|
399
413
|
} else {
|
|
400
414
|
// A hole in a tag name, between attributes, in a close tag or a markup
|
|
401
415
|
// declaration: nothing is emitted for it, as before.
|
|
@@ -3,9 +3,10 @@ import { unsafeHTML } from '../directives.js';
|
|
|
3
3
|
import { escapeAttr } from '../escape.js';
|
|
4
4
|
import { defaultSSRErrorTemplate, browserMemberHint } from './errors.js';
|
|
5
5
|
import {
|
|
6
|
-
|
|
6
|
+
applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
|
|
7
7
|
withHostMarker,
|
|
8
8
|
} from './attrs.js';
|
|
9
|
+
import { serveInstanceAttrs } from './served-attrs.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Custom-element expansion inside the op walk (#1646): what `injectDSD`
|
|
@@ -113,10 +114,11 @@ export async function settleRender(frame) {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
function afterRender(frame) {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
117
|
+
// Serve the attributes as the component left them up to and including
|
|
118
|
+
// render(): one it added is appended, one it changed carries its new value,
|
|
119
|
+
// and one it removed is dropped, as lit-ssr's renderAttributes() does (see
|
|
120
|
+
// served-attrs.js). Reading after render() captures all.
|
|
121
|
+
frame.opening = serveInstanceAttrs(frame.opening, frame.instance, frame.presentAttrNames);
|
|
120
122
|
// Mark LIGHT-DOM hosts for the `@layer webjs-host` display:block default.
|
|
121
123
|
// Shadow hosts are NOT marked: a document-level rule on the host beats the
|
|
122
124
|
// shadow tree's own `:host { display }`.
|
|
@@ -253,14 +255,39 @@ async function errorState(w, frame) {
|
|
|
253
255
|
* The `slot` attribute value of an element's attribute text, or null when
|
|
254
256
|
* absent. `slot=""` and the reserved alias `default` both mean the default
|
|
255
257
|
* slot (#1015), matching the client record.
|
|
258
|
+
*
|
|
259
|
+
* The attributes are read one by one, the way the HTML tokenizer reads them,
|
|
260
|
+
* rather than by searching for `slot=`. A search has two false matches: the
|
|
261
|
+
* tail of another name (`data-slot="button"`, which every shadcn-shaped part
|
|
262
|
+
* carries, since `\b` sits between the hyphen and the `s`) and text inside a
|
|
263
|
+
* quoted value (`title="a slot=b"`). Either one routed the child to a named
|
|
264
|
+
* slot the template does not have, and the server dropped it (#1666).
|
|
256
265
|
* @param {string} attrsRaw
|
|
257
266
|
* @returns {string | null}
|
|
258
267
|
*/
|
|
259
268
|
export function extractSlotAttr(attrsRaw) {
|
|
260
|
-
const
|
|
261
|
-
if (!
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
const found = findAttr(attrsRaw, 'slot');
|
|
270
|
+
if (!found) return null;
|
|
271
|
+
return found.value === '' || found.value === 'default' ? null : found.value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Find one attribute by name in an element's attribute text, reading the
|
|
276
|
+
* attributes in order as the HTML tokenizer does, so a name that merely ENDS
|
|
277
|
+
* in `attrName` or a quoted value that mentions it never matches. Returns
|
|
278
|
+
* the value (empty for a bare attribute) and the span of the whole attribute.
|
|
279
|
+
* @param {string} attrsRaw
|
|
280
|
+
* @param {string} attrName lowercase
|
|
281
|
+
* @returns {{ value: string, start: number, end: number } | null}
|
|
282
|
+
*/
|
|
283
|
+
function findAttr(attrsRaw, attrName) {
|
|
284
|
+
const attr = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
285
|
+
let m;
|
|
286
|
+
while ((m = attr.exec(attrsRaw))) {
|
|
287
|
+
if (m[1].toLowerCase() !== attrName) continue;
|
|
288
|
+
return { value: m[2] ?? m[3] ?? m[4] ?? '', start: m.index, end: m.index + m[0].length };
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
264
291
|
}
|
|
265
292
|
|
|
266
293
|
/**
|
|
@@ -278,9 +305,9 @@ export function finishSlot(frame, fallback) {
|
|
|
278
305
|
let name = null;
|
|
279
306
|
let otherAttrs = '';
|
|
280
307
|
if (NON_BLANK.test(attrsRaw)) {
|
|
281
|
-
const
|
|
282
|
-
name =
|
|
283
|
-
otherAttrs = attrsRaw.
|
|
308
|
+
const found = findAttr(attrsRaw, 'name');
|
|
309
|
+
name = found ? found.value : null;
|
|
310
|
+
otherAttrs = (found ? attrsRaw.slice(0, found.start) + attrsRaw.slice(found.end) : attrsRaw).trim();
|
|
284
311
|
}
|
|
285
312
|
const slotKey = name === 'default' || name === '' ? null : name;
|
|
286
313
|
const projected = owner.partition.get(slotKey);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { escapeAttr } from '../escape.js';
|
|
2
|
+
import { decodeAttrEntities } from './text.js';
|
|
3
|
+
import { appendReflectedAttrs } from './attrs.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serve a custom element's attributes as the element left them after the
|
|
7
|
+
* server pass, the way lit-ssr's `ElementRenderer.renderAttributes()` emits
|
|
8
|
+
* the instance's final attributes rather than the ones the tag was written
|
|
9
|
+
* with. The browser does the same after upgrade: an element that sets, changes
|
|
10
|
+
* or removes an attribute during its first update shows the result, so the
|
|
11
|
+
* served bytes now match the hydrated DOM instead of the source tag.
|
|
12
|
+
*
|
|
13
|
+
* Three cases, per attribute:
|
|
14
|
+
* - one the element added is appended (`appendReflectedAttrs`, unchanged);
|
|
15
|
+
* - one the element CHANGED is served with its new value;
|
|
16
|
+
* - one the element REMOVED is dropped from the tag.
|
|
17
|
+
* An attribute the element left alone keeps its source text BYTE FOR BYTE
|
|
18
|
+
* (its quoting, its entity spelling, its position), so a page whose
|
|
19
|
+
* components touch nothing renders exactly as before, which the elision
|
|
20
|
+
* on-versus-off differential relies on.
|
|
21
|
+
*
|
|
22
|
+
* What stays as written, and why each is safe:
|
|
23
|
+
* - `data-webjs-prop-*`: a parent's `.prop=${value}` binding, consumed into
|
|
24
|
+
* the property BEFORE the instance existed (`consumePropAttrs`), so the
|
|
25
|
+
* instance never holds it and reading it back would drop it. It is kept as
|
|
26
|
+
* written, because the browser decodes it again on upgrade.
|
|
27
|
+
* - `data-wj-host`: added AFTER this step by `withHostMarker`. When a
|
|
28
|
+
* re-processed string already carries it (a streamed boundary's children),
|
|
29
|
+
* it was seeded into the instance like any other attribute and is
|
|
30
|
+
* unchanged, so it is kept.
|
|
31
|
+
* - `data-wj-slot-owner`: the renderer stamps it on `<slot>` elements, not on
|
|
32
|
+
* hosts. A host can only carry it in a re-processed string, where it was
|
|
33
|
+
* seeded and is unchanged, so it is kept.
|
|
34
|
+
* - an attribute that came from a template hole: by this point its value is
|
|
35
|
+
* plain text in the opening tag, seeded like a static one, so the same
|
|
36
|
+
* rule applies. Serving the element's value cannot desynchronize the hole:
|
|
37
|
+
* a host inside a component's template is re-created by that component's
|
|
38
|
+
* client render, which writes the hole's value again, and a host written
|
|
39
|
+
* by a page or a layout never hydrates its attributes at all.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} opening the element's opening tag, `<tag attrs>`, ending in `>`
|
|
42
|
+
* @param {any} instance the server-side instance (the element shim, or a real
|
|
43
|
+
* `HTMLElement` when `renderToString` runs in a browser)
|
|
44
|
+
* @param {Set<string>} presentAttrNames lowercased names already in the source tag
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function serveInstanceAttrs(opening, instance, presentAttrNames) {
|
|
48
|
+
if (!instance || typeof instance.getAttribute !== 'function' || typeof instance.getAttributeNames !== 'function') return opening;
|
|
49
|
+
const nameEnd = opening.search(/[\s>]/);
|
|
50
|
+
const head = opening.slice(0, nameEnd);
|
|
51
|
+
const body = opening.slice(nameEnd, -1);
|
|
52
|
+
// The same attribute grammar `parseAttrs` reads the source with, so every
|
|
53
|
+
// name seeded into the instance is found again here.
|
|
54
|
+
const re = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
55
|
+
let out = '';
|
|
56
|
+
let last = 0;
|
|
57
|
+
let changed = false;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = re.exec(body)) !== null) {
|
|
60
|
+
const raw = m[0];
|
|
61
|
+
const name = m[1];
|
|
62
|
+
if (name.toLowerCase().startsWith('data-webjs-prop-')) continue;
|
|
63
|
+
const written = decodeAttrEntities(m[2] ?? m[3] ?? m[4] ?? '');
|
|
64
|
+
const now = instance.getAttribute(name);
|
|
65
|
+
if (now === written) continue;
|
|
66
|
+
changed = true;
|
|
67
|
+
if (now === null) {
|
|
68
|
+
// Removed: drop it together with the whitespace that separated it.
|
|
69
|
+
let start = m.index;
|
|
70
|
+
while (start > last && /\s/.test(body[start - 1])) start--;
|
|
71
|
+
out += body.slice(last, start);
|
|
72
|
+
} else {
|
|
73
|
+
out += body.slice(last, m.index) + (now === '' ? name : `${name}="${escapeAttr(String(now))}"`);
|
|
74
|
+
}
|
|
75
|
+
last = m.index + raw.length;
|
|
76
|
+
}
|
|
77
|
+
const rewritten = changed ? `${head}${out}${body.slice(last)}>` : opening;
|
|
78
|
+
return appendReflectedAttrs(rewritten, instance, presentAttrNames);
|
|
79
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAttributeMap, attributeMapText } from '../attribute-directives.js';
|
|
2
|
+
import { nothing, noChange } from '../sentinels.js';
|
|
1
3
|
import { isTemplate } from '../html.js';
|
|
2
4
|
import { attrHoleValue } from '../binding-prefixes.js';
|
|
3
5
|
import { escapeText, escapeAttr } from '../escape.js';
|
|
@@ -163,7 +165,7 @@ export class Walker {
|
|
|
163
165
|
* @param {unknown} value
|
|
164
166
|
*/
|
|
165
167
|
async value(value) {
|
|
166
|
-
if (value == null || value ===
|
|
168
|
+
if (value == null || value === nothing || value === noChange) return;
|
|
167
169
|
if (typeof (/** @type any */ (value).then) === 'function') {
|
|
168
170
|
return this.value(await value);
|
|
169
171
|
}
|
|
@@ -300,6 +302,38 @@ export class Walker {
|
|
|
300
302
|
await this.attr(op, val);
|
|
301
303
|
break;
|
|
302
304
|
case VALUE:
|
|
305
|
+
if (op.quote) {
|
|
306
|
+
// Resolve the complete attribute before emitting any of it so a
|
|
307
|
+
// sentinel in any expression can omit the entire attribute.
|
|
308
|
+
this.buf = this.buf.slice(0, -op.prefix.length);
|
|
309
|
+
let attribute = op.prefix;
|
|
310
|
+
let omitted = false;
|
|
311
|
+
let piece = val;
|
|
312
|
+
for (let j = i; j < ops.length; j++) {
|
|
313
|
+
const next = ops[j];
|
|
314
|
+
if (next.t === HOLE) {
|
|
315
|
+
if (j !== i) {
|
|
316
|
+
piece = values[vi++];
|
|
317
|
+
if (piece && typeof piece.then === 'function') piece = await piece;
|
|
318
|
+
if (isLive(piece)) piece = piece.value;
|
|
319
|
+
}
|
|
320
|
+
if (isAttributeMap(piece)) piece = attributeMapText(piece, op.name);
|
|
321
|
+
assertNotFunctionActionAttr(piece, op.name, op.tag);
|
|
322
|
+
if (piece === nothing) omitted = true;
|
|
323
|
+
attribute += piece === nothing || piece === noChange ? '' : escapeAttr(String(piece ?? ''));
|
|
324
|
+
} else if (next.t === TEXT) {
|
|
325
|
+
const close = next.s.indexOf(op.quote);
|
|
326
|
+
if (close >= 0) {
|
|
327
|
+
if (!omitted) this.buf += attribute + next.s.slice(0, close + 1);
|
|
328
|
+
this.buf += next.s.slice(close + 1);
|
|
329
|
+
i = j;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
attribute += next.s;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
303
337
|
// A hole inside a quoted/unquoted value, the `action="${fn}"` and
|
|
304
338
|
// mixed `action="/x/${fn}"` shapes (#1154).
|
|
305
339
|
assertNotFunctionActionAttr(val, op.name, op.tag);
|
|
@@ -366,6 +400,12 @@ export class Walker {
|
|
|
366
400
|
* @param {unknown} val
|
|
367
401
|
*/
|
|
368
402
|
async attr(op, val) {
|
|
403
|
+
if (isAttributeMap(val)) {
|
|
404
|
+
if (op.kind !== 'plain') throw new Error('classMap/styleMap require an attribute binding.');
|
|
405
|
+
val = attributeMapText(val, op.name);
|
|
406
|
+
}
|
|
407
|
+
if (val === noChange) return;
|
|
408
|
+
if (val === nothing && op.kind === 'prop') val = undefined;
|
|
369
409
|
const ts = this.tagState;
|
|
370
410
|
const name = /** @type string */ (op.name);
|
|
371
411
|
const tag = /** @type string */ (op.tag);
|
|
@@ -412,7 +452,7 @@ export class Walker {
|
|
|
412
452
|
// `?action=${fn}` is meaningless in every case; refusing it keeps the
|
|
413
453
|
// rule true for every sigil rather than only the quoted ones.
|
|
414
454
|
assertNotFunctionActionAttr(val, name, tag);
|
|
415
|
-
if (val) this.buf += `${name}=""`;
|
|
455
|
+
if (val && val !== nothing) this.buf += `${name}=""`;
|
|
416
456
|
return;
|
|
417
457
|
}
|
|
418
458
|
// A plain `name=${value}` hole.
|
|
@@ -37,8 +37,10 @@ export function closestAnchor(target) {
|
|
|
37
37
|
const a = el.closest && el.closest('a');
|
|
38
38
|
if (a instanceof HTMLAnchorElement) return a;
|
|
39
39
|
}
|
|
40
|
+
// Only a real ShadowRoot has a host to cross to (a detached subtree's
|
|
41
|
+
// root can be an <a>, whose `host` is a URL string).
|
|
40
42
|
const root = node.getRootNode ? node.getRootNode() : null;
|
|
41
|
-
node =
|
|
43
|
+
node = typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot ? root.host : null;
|
|
42
44
|
}
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
@@ -159,6 +159,20 @@ export function collectBoundaries(root) {
|
|
|
159
159
|
* live: { routeKey: string, start: Comment, end: Comment },
|
|
160
160
|
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
161
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* Does segment `inner` sit at or below segment `outer`. Boundary segments are
|
|
164
|
+
* nested path prefixes, so `/ui/docs` is within `/ui` and within `/`, and
|
|
165
|
+
* `/uix` is within neither `/ui` nor `/ui/docs`. The one statement of the
|
|
166
|
+
* nesting rule: the swap plan below and the stylesheet ownership in
|
|
167
|
+
* owned-styles.js both use it, so they cannot disagree about which range a
|
|
168
|
+
* segment belongs to.
|
|
169
|
+
* @param {string} inner
|
|
170
|
+
* @param {string} outer
|
|
171
|
+
*/
|
|
172
|
+
export function segmentWithin(inner, outer) {
|
|
173
|
+
return inner === outer || inner.startsWith(outer === '/' ? '/' : outer + '/');
|
|
174
|
+
}
|
|
175
|
+
|
|
162
176
|
export function planBoundarySwap(here, there) {
|
|
163
177
|
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
164
178
|
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
@@ -169,7 +183,7 @@ export function planBoundarySwap(here, there) {
|
|
|
169
183
|
let parent = null;
|
|
170
184
|
for (const p of shared) {
|
|
171
185
|
if (p === seg) break;
|
|
172
|
-
if (seg
|
|
186
|
+
if (seg !== p && segmentWithin(seg, p)) parent = p;
|
|
173
187
|
}
|
|
174
188
|
if (!parent) return null; // no anchored parent: degrade
|
|
175
189
|
return { mode: 'replace', segment: parent, live: here.get(parent), incoming: there.get(parent) };
|
|
@@ -204,3 +204,33 @@ export function _isNonHtmlPath(pathname) {
|
|
|
204
204
|
* alone rather than reverted.
|
|
205
205
|
*/
|
|
206
206
|
export const WRITE_BACK_ARMED_MS = 250;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Marks the router's temporary stylesheet copies (`stylesheets.js`). Here,
|
|
210
|
+
* in the leaf module, so `owned-styles.js` can skip those copies without
|
|
211
|
+
* importing `stylesheets.js`, which imports `head-merge.js`, which imports
|
|
212
|
+
* `owned-styles.js`.
|
|
213
|
+
*/
|
|
214
|
+
export const PRELOAD_ATTR = 'data-webjs-preload-style';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Does `el`'s `rel` name a stylesheet that applies (`stylesheet`, not
|
|
218
|
+
* `alternate`). The one test both the stylesheet loader (`stylesheets.js`) and
|
|
219
|
+
* the owned-sheet merge (`owned-styles.js`) use, so the two cannot disagree.
|
|
220
|
+
* @param {Element} el
|
|
221
|
+
*/
|
|
222
|
+
export function relIsStylesheet(el) {
|
|
223
|
+
const rel = relTokens(el);
|
|
224
|
+
return rel.includes('stylesheet') && !rel.includes('alternate');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The lowercased tokens of `el`'s `rel`, the one parse every stylesheet test
|
|
229
|
+
* in the router builds on (`relIsStylesheet` here, and the head merge's
|
|
230
|
+
* never-remove rule, which counts an alternate stylesheet too).
|
|
231
|
+
* @param {Element} el
|
|
232
|
+
* @returns {string[]}
|
|
233
|
+
*/
|
|
234
|
+
export function relTokens(el) {
|
|
235
|
+
return (el.getAttribute('rel') || '').toLowerCase().split(/\s+/);
|
|
236
|
+
}
|
|
@@ -19,6 +19,7 @@ import { navigate } from './navigator.js';
|
|
|
19
19
|
import { prefetchTake } from './prefetch.js';
|
|
20
20
|
import { currentNavigationToken } from './state.js';
|
|
21
21
|
import { readStreamedShell, streamBoundariesProgressively } from './stream.js';
|
|
22
|
+
import { loadIncomingStylesheets, releaseIncomingStylesheets } from './stylesheets.js';
|
|
22
23
|
import { _swapCommit, applySwap } from './swap.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -295,6 +296,21 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
295
296
|
// recover in place rather than a destructive full reload.
|
|
296
297
|
if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }
|
|
297
298
|
|
|
299
|
+
// Load the stylesheets the incoming page brings before it is swapped in, so
|
|
300
|
+
// its first paint is styled (see stylesheets.js). Null when it brings none,
|
|
301
|
+
// and the swap then stays in this task. A newer navigation that started
|
|
302
|
+
// during the wait owns the page, exactly as after the body read above, and
|
|
303
|
+
// this one's temporary copies go with it.
|
|
304
|
+
const sheets = loadIncomingStylesheets(doc, finalUrl || href, frameId);
|
|
305
|
+
if (sheets) {
|
|
306
|
+
await sheets.ready;
|
|
307
|
+
if (myToken !== currentNavigationToken) {
|
|
308
|
+
releaseIncomingStylesheets(sheets);
|
|
309
|
+
if (streamCtx && streamCtx.reader) { try { streamCtx.reader.cancel(); } catch { /* ignore */ } }
|
|
310
|
+
return { ok: false, status: respStatus, aborted: true, applied: false };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
298
314
|
// #1406: recording the push after the swap finalizes the outgoing page's
|
|
299
315
|
// entry against the DESTINATION document, at a scroll offset the browser has
|
|
300
316
|
// already clamped to that document's height (measured on the gallery: 1600 to
|
|
@@ -336,6 +352,11 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
336
352
|
: null;
|
|
337
353
|
|
|
338
354
|
const disposition = applySwap(doc, frameId, !!revalidating, finalUrl, incomingBuild, incomingSrc, refresh || (mutation ? 'layouts' : undefined), recordHistoryNow);
|
|
355
|
+
// The temporary stylesheet copies are released once the swap has COMMITTED:
|
|
356
|
+
// under a view transition the new markup, and the page's own links with it,
|
|
357
|
+
// lands a frame later. A copy with no twin in the result (a discarded or
|
|
358
|
+
// uncommitted swap) goes as soon as the commit settles.
|
|
359
|
+
if (sheets) _swapCommit.then(() => releaseIncomingStylesheets(sheets), () => releaseIncomingStylesheets(sheets));
|
|
339
360
|
// `'none'` means applySwap returned WITHOUT committing anything: the frame the
|
|
340
361
|
// response was for is missing, or it degraded to a hard navigation (an
|
|
341
362
|
// importmap/build mismatch, a poisoned boundary scan). The page is not left
|
|
@@ -24,10 +24,13 @@ export function activeFrameId(el) {
|
|
|
24
24
|
while (cur) {
|
|
25
25
|
const frame = cur.closest('webjs-frame');
|
|
26
26
|
if (frame && frame.id) return frame.id;
|
|
27
|
-
// Cross shadow boundary upwards if necessary.
|
|
27
|
+
// Cross a shadow boundary upwards if necessary. Only a real ShadowRoot
|
|
28
|
+
// has a host to cross to: a DETACHED element's root is the top of its
|
|
29
|
+
// own subtree, and when that is an <a>, its `host` is the URL's host
|
|
30
|
+
// STRING, which the next closest() call threw on.
|
|
28
31
|
const root = cur.getRootNode();
|
|
29
|
-
if (
|
|
30
|
-
cur =
|
|
32
|
+
if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {
|
|
33
|
+
cur = root.host;
|
|
31
34
|
} else {
|
|
32
35
|
break;
|
|
33
36
|
}
|