@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.
Files changed (44) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +19 -19
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +6 -6
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +5 -1
  8. package/index.d.ts +5 -1
  9. package/index.js +5 -1
  10. package/package.json +1 -1
  11. package/src/attribute-directives.d.ts +6 -0
  12. package/src/attribute-directives.js +49 -0
  13. package/src/attribute-reader.js +17 -3
  14. package/src/binding-prefixes.js +10 -41
  15. package/src/component/lifecycle.js +177 -59
  16. package/src/component/reactive.js +5 -4
  17. package/src/component.d.ts +11 -5
  18. package/src/directives.d.ts +2 -0
  19. package/src/directives.js +2 -0
  20. package/src/html.d.ts +4 -0
  21. package/src/html.js +22 -1
  22. package/src/render-client/adopt-slots.js +245 -0
  23. package/src/render-client/parts.js +51 -17
  24. package/src/render-client/reconciler.js +28 -12
  25. package/src/render-client/template-compiler.js +19 -5
  26. package/src/render-client.d.ts +2 -1
  27. package/src/render-server/compile.js +16 -2
  28. package/src/render-server/expand.js +39 -12
  29. package/src/render-server/served-attrs.js +79 -0
  30. package/src/render-server/walk.js +42 -2
  31. package/src/router-client/anchors.js +3 -1
  32. package/src/router-client/boundaries.js +15 -1
  33. package/src/router-client/constants.js +30 -0
  34. package/src/router-client/fetch-apply.js +21 -0
  35. package/src/router-client/frames.js +6 -3
  36. package/src/router-client/head-merge.js +58 -10
  37. package/src/router-client/navigator.js +4 -0
  38. package/src/router-client/owned-styles.js +372 -0
  39. package/src/router-client/prefetch.js +3 -0
  40. package/src/router-client/stylesheets.js +191 -0
  41. package/src/router-client/swap.js +49 -9
  42. package/src/sentinels.d.ts +2 -0
  43. package/src/sentinels.js +4 -0
  44. 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 will be replaced with identical output -
48
- // no visible flash because SSR and client render produce the same HTML.
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
- if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
51
- firstChild.remove();
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 === false || value === true) return;
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
- /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
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: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
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
- templateEl.innerHTML = html;
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
@@ -1 +1,2 @@
1
- export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot): void;
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
- appendReflectedAttrs, applyAttrsToInstance, consumePropAttrs, parseAttrs, seedServerAttrs,
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
- // Surface attributes the component set up to and including render() that
117
- // were not in the source tag (reflected props, an explicit setAttribute,
118
- // a host mutation inside render()). Reading after render() captures all.
119
- frame.opening = appendReflectedAttrs(frame.opening, frame.instance, frame.presentAttrNames);
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 m = /\bslot\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
261
- if (!m) return null;
262
- const value = m[1] ?? m[2] ?? m[3] ?? '';
263
- return value === '' || value === 'default' ? null : value;
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 nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(attrsRaw);
282
- name = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : null;
283
- otherAttrs = attrsRaw.replace(/\bname\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '').trim();
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 === false || value === true) return;
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 = root && /** @type any */ (root).host ? /** @type any */ (root).host : null;
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.startsWith(p === '/' ? p : p + '/')) parent = p;
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 (root && /** @type any */ (root).host) {
30
- cur = /** @type any */ (root).host;
32
+ if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot) {
33
+ cur = root.host;
31
34
  } else {
32
35
  break;
33
36
  }