@webjsdev/core 0.7.65 → 0.9.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 (92) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +22 -21
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
package/src/metadata.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * export const metadata: Metadata = { title: 'Home', description: '…' };
12
12
  * export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> { … }
13
13
  *
14
- * The shape mirrors what ssr.js actually consumes (and the skill's
15
- * references/routing-and-pages.md documents), NOT Next.js's full superset.
14
+ * The shape mirrors what ssr.js actually consumes (and
15
+ * https://webjs.dev/docs/metadata-routes documents), NOT Next.js's full superset.
16
16
  * Every field is optional. Zero
17
17
  * runtime cost: nothing in this file ships to the browser.
18
18
  */
@@ -209,7 +209,7 @@ export interface PreloadDescriptor {
209
209
  * optional. Where the framework accepts a string OR an object (`title`,
210
210
  * `viewport`, `robots`, `appleWebApp`, `icons`), the type is a union.
211
211
  *
212
- * @see the skill's references/routing-and-pages.md for the field-by-field emission reference.
212
+ * @see https://webjs.dev/docs/metadata-routes for the field-by-field emission reference.
213
213
  */
214
214
  export interface Metadata {
215
215
  /** -> `<title>`. Plain string or `{ template, default, absolute }`. */
@@ -337,3 +337,22 @@ export interface MetadataContext {
337
337
  */
338
338
  actionData?: unknown;
339
339
  }
340
+
341
+ /**
342
+ * A page's or layout's `generateMetadata` export, Next.js's: called per
343
+ * request with the page context and a promise of the metadata the outer
344
+ * layouts resolved, it returns this segment's {@link Metadata}. Export a static
345
+ * `metadata` object instead when nothing depends on the request.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * // app/posts/[slug]/page.ts
350
+ * import type { Metadata, MetadataContext } from '@webjsdev/core';
351
+ * export async function generateMetadata({ params }: MetadataContext, parent: Promise<Metadata>): Promise<Metadata> {
352
+ * const post = await getPost(params.slug);
353
+ * const images = (await parent).openGraph?.images ?? [];
354
+ * return { title: post?.title ?? 'Not found', openGraph: { images } };
355
+ * }
356
+ * ```
357
+ */
358
+ export type GenerateMetadata = (ctx: MetadataContext, parent: Promise<Metadata>) => Metadata | Promise<Metadata>;
package/src/nav.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export function notFound(): never;
2
- export function redirect(url: string, status?: number | { status?: number }): never;
2
+ /** How the client records a redirect that reaches the browser (Next's RedirectType). */
3
+ export type RedirectType = 'push' | 'replace';
4
+ export function redirect(url: string, status?: number | RedirectType | { status?: number; type?: RedirectType }): never;
5
+ export function permanentRedirect(url: string, type?: RedirectType): never;
3
6
  export function forbidden(): never;
4
7
  export function unauthorized(): never;
5
8
  export function isNotFound(e: unknown): boolean;
package/src/nav.js CHANGED
@@ -59,31 +59,64 @@ export function unauthorized() {
59
59
  * each kind of redirect gets the conventional code:
60
60
  * - thrown during a GET page/layout render (a gating redirect like an auth
61
61
  * bounce to `/login`): 302 Found, the conventional GET-to-GET code.
62
- * - thrown from a server action (a POST): 307 Temporary Redirect, which is
63
- * method-preserving so the action's intent survives. (The PRG success
64
- * path uses 303 separately.)
62
+ * - thrown from a `route.ts` handler: 307 Temporary Redirect.
63
+ * - thrown from a server action bound to a form: always 303 See Other, so
64
+ * the browser follows with a GET and the action runs once (Next parity).
65
+ * - thrown from a server action called over RPC: the client router
66
+ * navigates to the target.
65
67
  * Pass an explicit status to override: `redirect('/x', 308)` (permanent) or the
66
- * options form `redirect('/x', { status: 301 })`. An explicit status wins at
67
- * every catching site.
68
+ * options form `redirect('/x', { status: 301 })`. An explicit status wins
69
+ * everywhere except a form-bound action, which always answers 303.
70
+ *
71
+ * The second argument may instead be `'push'` or `'replace'` (Next's
72
+ * `RedirectType`): how the client records the navigation when the redirect
73
+ * reaches the browser from an action called over RPC. `'push'` is the
74
+ * default there, as in a Next.js server action.
68
75
  *
69
76
  * @param {string} url
70
- * @param {number | { status?: number }} [status] explicit status (number) or
71
- * `{ status }`; omit to let the catching site pick (302 for a GET gate, 307
72
- * for an action).
77
+ * @param {number | RedirectType | { status?: number, type?: RedirectType }} [status]
78
+ * explicit status (number), a `RedirectType`, or `{ status, type }`; omit to
79
+ * let the catching site pick.
73
80
  * @returns {never}
74
81
  */
75
82
  export function redirect(url, status) {
76
- const code = typeof status === 'object' && status !== null ? status.status : status;
83
+ /** @type {number | undefined} */
84
+ let code;
85
+ /** @type {RedirectType | undefined} */
86
+ let type;
87
+ if (status === 'push' || status === 'replace') type = status;
88
+ else if (typeof status === 'object' && status !== null) {
89
+ code = status.status;
90
+ if (status.type === 'push' || status.type === 'replace') type = status.type;
91
+ } else if (typeof status === 'number') code = status;
77
92
  const err = new Error(`webjs: redirect(${url})`);
78
93
  /** @type any */ (err).__webjs = REDIRECT;
79
94
  /** @type any */ (err).url = url;
80
95
  // Left undefined when the caller did not specify one, so the catching site
81
- // can apply its convention (302 GET gate vs 307 action). A caller-supplied
82
- // code is stored verbatim and overrides that convention.
96
+ // can apply its convention (302 GET gate, 307 route handler, 303 form action).
97
+ // A caller-supplied code is stored verbatim and overrides that convention,
98
+ // except at a form-bound action, which always answers 303.
83
99
  /** @type any */ (err).status = typeof code === 'number' ? code : undefined;
100
+ /** @type any */ (err).redirectType = type;
84
101
  throw err;
85
102
  }
86
103
 
104
+ /**
105
+ * Throw a PERMANENT redirect sentinel, Next.js's `permanentRedirect()`: a 308
106
+ * Permanent Redirect everywhere except a form-bound action, which answers 303. Use it
107
+ * when a URL has moved for good (a renamed slug); browsers and search engines
108
+ * remember a 308.
109
+ *
110
+ * @param {string} url
111
+ * @param {RedirectType} [type] how an RPC caller's client records the navigation.
112
+ * @returns {never}
113
+ */
114
+ export function permanentRedirect(url, type) {
115
+ return redirect(url, { status: 308, type });
116
+ }
117
+
118
+ /** @typedef {'push' | 'replace'} RedirectType */
119
+
87
120
  /** @param {unknown} e */
88
121
  export function isNotFound(e) {
89
122
  return !!e && /** @type any */ (e).__webjs === NOT_FOUND;
package/src/optimistic.js CHANGED
@@ -108,8 +108,8 @@ async function runImperativeOptimistic(signal, value, action) {
108
108
  * Imperative form (signal-based rollback), for a one-shot toggle:
109
109
  * await optimistic(signal, value, () => likePost(postId));
110
110
  *
111
- * Client-only: it calls `host.requestUpdate()` (client work), so a component
112
- * importing it is never elided as display-only.
111
+ * Client-only: it calls `host.requestUpdate()` (client work), so it belongs in
112
+ * a component, never in a server-only one.
113
113
  *
114
114
  * @template State
115
115
  * @template Action
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `x-webjs-refresh` header a server action's response carries when it
3
+ * called `revalidatePath()`. A leaf module (no imports), so the router's
4
+ * fetch path and the action stub's effects can both read it without an
5
+ * import cycle.
6
+ *
7
+ * @module refresh-header
8
+ */
9
+
10
+ /**
11
+ * Parse an `x-webjs-refresh` header: comma-separated, percent-encoded paths,
12
+ * each optionally suffixed `;layout` (Next's `revalidatePath(path, 'layout')`).
13
+ * @param {string | null | undefined} value
14
+ * @returns {{ path: string, layout: boolean }[]}
15
+ */
16
+ export function parseRefreshHeader(value) {
17
+ if (!value) return [];
18
+ /** @type {{ path: string, layout: boolean }[]} */
19
+ const out = [];
20
+ for (const part of String(value).split(',')) {
21
+ const raw = part.trim();
22
+ if (!raw) continue;
23
+ const layout = raw.endsWith(';layout');
24
+ let path = layout ? raw.slice(0, -';layout'.length) : raw;
25
+ try { path = decodeURIComponent(path); } catch { /* keep as sent */ }
26
+ if (path.startsWith('/')) out.push({ path, layout });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Whether `pathname` is covered by one refreshed entry. A `[param]` segment
33
+ * matches any one segment, a `[...param]` segment one or more and a
34
+ * `[[...param]]` segment zero or more of the rest
35
+ * (Next lets `revalidatePath` name a route pattern); a `layout` entry also
36
+ * covers every path below it.
37
+ * @param {{ path: string, layout: boolean }} entry
38
+ * @param {string} pathname
39
+ */
40
+ export function refreshCovers(entry, pathname) {
41
+ const want = entry.path.split('/').filter(Boolean);
42
+ const have = pathname.split('/').filter(Boolean);
43
+ for (let i = 0; i < want.length; i++) {
44
+ const w = want[i];
45
+ if (/^\[\[\.\.\./.test(w)) return true;
46
+ if (/^\[\.\.\./.test(w)) return have.length > i;
47
+ if (i >= have.length) return false;
48
+ if (/^\[[^\]]+\]$/.test(w)) continue;
49
+ if (w !== have[i]) return false;
50
+ }
51
+ return entry.layout || have.length === want.length;
52
+ }
package/src/registry.js CHANGED
@@ -17,6 +17,8 @@
17
17
  * @typedef {{ cls: typeof import('./component.js').WebComponent, moduleUrl: string | null, lazy: boolean, tag: string }} RegistryEntry
18
18
  */
19
19
 
20
+ import { hotReplacer } from './hot.js';
21
+
20
22
  /* ------------------------------------------------------------------
21
23
  * Shared registry on globalThis.
22
24
  *
@@ -106,7 +108,11 @@ function registerInternal(tag, cls) {
106
108
  if (isBrowser) {
107
109
  const native = customElements.define.bind(customElements);
108
110
  /** @type {any} */ (customElements).define = function (tag, cls, options) {
109
- if (!customElements.get(tag)) native(tag, cls, options);
111
+ const prev = customElements.get(tag);
112
+ if (!prev) native(tag, cls, options);
113
+ // A re-define of a known tag under the dev hot client (#1689) patches the
114
+ // registered class in place, so the registry keeps the class the browser uses.
115
+ else if (prev !== cls) { const replace = hotReplacer(); if (replace) cls = replace(tag, prev, cls); }
110
116
  registerInternal(tag, cls);
111
117
  };
112
118
  }
@@ -152,6 +158,9 @@ if (!isBrowser) {
152
158
  * @param {typeof import('./component.js').WebComponent} cls
153
159
  */
154
160
  export function register(tag, cls) {
161
+ // Lit's `finalize()`, so the class's metadata is ready before the first
162
+ // instance (a class not built on WebComponent has none).
163
+ if (typeof (/** @type {any} */ (cls)).finalize === 'function') /** @type {any} */ (cls).finalize();
155
164
  if (isBrowser) {
156
165
  /** @type any */ (customElements).define(tag, cls);
157
166
  } else {
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Hydration slot adoption: keep a light-DOM host's SSR-projected children in
3
+ * the document while its first client render replaces the SSR markup (#1666).
4
+ *
5
+ * Hydration renders the template from a fresh clone and swaps it in for the
6
+ * SSR markup. The projected children sit inside the SSR `<slot>`, so swapping
7
+ * the SSR tree out took them out of the document, and the first slot apply put
8
+ * them back a microtask later. A removal is not free: the HTML spec navigates
9
+ * a re-inserted iframe again (and `loading="lazy"` defers only the FIRST
10
+ * insertion, so a hidden lazy frame loads), and every nested custom element
11
+ * disconnects and reconnects.
12
+ *
13
+ * So for each of the template's own slots whose SSR counterpart holds exactly
14
+ * the nodes the host's record adopted, the SSR slot and its ancestor chain up
15
+ * to the host are KEPT: the parts that target a fresh element on that chain
16
+ * are pointed at its live twin, and the fresh siblings around the chain are
17
+ * inserted into the live elements instead of the live tree being replaced. No
18
+ * kept node moves, so the projected children never leave the document. The
19
+ * fresh slot's cloned fallback goes to the slot's holding fragment, where the
20
+ * slot part would have put it.
21
+ *
22
+ * Conservative by construction: a slot is adopted only when every link of
23
+ * both chains matches by tag, no link is a custom element (whose SSR markup is
24
+ * its own rendered template) or a `<form>` (form-action reconciliation inserts
25
+ * children into it), the slot's name is static, and the kept nodes sit in the
26
+ * same relative order on both sides. Anything else, a slot inside a
27
+ * conditional template among them, falls back to the plain swap.
28
+ *
29
+ * @module
30
+ */
31
+ import {
32
+ PROJECTION_ACTUAL, PROJECTION_ATTR, SLOT_FALLBACK_FRAG, SLOT_OWNER_ATTR,
33
+ ensureSlotState, hasSlotState, keyOfName,
34
+ } from '../slot.js';
35
+
36
+ /**
37
+ * Live SSR slots a hydration render adopted. The slot part's finalize reads
38
+ * it to leave the slot's children (the projected nodes) in place instead of
39
+ * harvesting them as fallback, since the holding fragment is already set.
40
+ * @type {WeakSet<Element>}
41
+ */
42
+ export const adoptedSlots = new WeakSet();
43
+
44
+ /**
45
+ * @typedef {{ map: Map<Node, Element>, slots: [Element, Element][], chains: [Element, Element][] }} AdoptionPlan
46
+ */
47
+
48
+ /**
49
+ * Plan the adoption for a hydration render, after the parts are bound and
50
+ * before any is applied. Returns null when no slot qualifies.
51
+ *
52
+ * @param {Element | DocumentFragment | ShadowRoot} container
53
+ * @param {DocumentFragment} frag the fresh clone
54
+ * @param {any[]} bound the template's bound parts
55
+ * @returns {AdoptionPlan | null}
56
+ */
57
+ export function planSlotAdoption(container, frag, bound) {
58
+ if (/** @type {any} */ (container).nodeType !== 1 || !hasSlotState(/** @type {Element} */ (container))) return null;
59
+ const state = ensureSlotState(/** @type {Element} */ (container));
60
+ /** @type {Set<Element>} */
61
+ const dynamicName = new Set();
62
+ for (const p of bound) {
63
+ if ((p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool') && p.name === 'name') dynamicName.add(p.el);
64
+ }
65
+ /** @type {Map<Node, Element>} fresh element to its live twin */
66
+ const map = new Map();
67
+ /** @type {Set<Element>} */
68
+ const usedLive = new Set();
69
+ /** @type {[Element, Element][]} */
70
+ const slots = [];
71
+ /** @type {[Element, Element][]} */
72
+ const chains = [];
73
+ for (const p of bound) {
74
+ if (p.kind !== 'slot') continue;
75
+ const slotF = /** @type {Element} */ (p.slotEl);
76
+ if (dynamicName.has(slotF)) continue;
77
+ const assigned = state.assignedByName.get(keyOfName(slotF.getAttribute('name')));
78
+ if (!assigned || assigned.length === 0) continue;
79
+ const slotL = /** @type {Element | null} */ (assigned[0].parentNode);
80
+ if (!slotL || slotL.nodeType !== 1 || slotL.localName !== 'slot' || usedLive.has(slotL)) continue;
81
+ if (slotL.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL) continue;
82
+ if (!sameNodes(slotL.childNodes, assigned)) continue;
83
+ const fChain = ancestorsUpTo(slotF, frag);
84
+ const lChain = ancestorsUpTo(slotL, container);
85
+ if (!fChain || !lChain || fChain.length !== lChain.length) continue;
86
+ let ok = true;
87
+ for (let i = 0; i < fChain.length && ok; i++) {
88
+ const f = fChain[i];
89
+ const l = lChain[i];
90
+ const name = f.localName;
91
+ if (name !== l.localName || name.includes('-') || name === 'form') ok = false;
92
+ else {
93
+ const prior = map.get(f);
94
+ if (prior ? prior !== l : usedLive.has(l)) ok = false;
95
+ }
96
+ }
97
+ if (!ok) continue;
98
+ map.set(slotF, slotL);
99
+ usedLive.add(slotL);
100
+ slots.push([slotF, slotL]);
101
+ for (let i = 0; i < fChain.length; i++) {
102
+ if (map.has(fChain[i])) continue;
103
+ map.set(fChain[i], lChain[i]);
104
+ usedLive.add(lChain[i]);
105
+ chains.push([fChain[i], lChain[i]]);
106
+ }
107
+ }
108
+ if (slots.length === 0) return null;
109
+ // No kept node may have to move: under each kept parent the kept children
110
+ // must already sit in the order the template puts them in.
111
+ for (const fParent of [frag, ...chains.map(([f]) => f)]) {
112
+ let last = null;
113
+ for (const child of fParent.childNodes) {
114
+ const live = map.get(child);
115
+ if (!live) continue;
116
+ if (last && !(last.compareDocumentPosition(live) & Node.DOCUMENT_POSITION_FOLLOWING)) return null;
117
+ last = live;
118
+ }
119
+ }
120
+ return { map, slots, chains };
121
+ }
122
+
123
+ /**
124
+ * Point every bound part that targets a kept fresh element at its live twin,
125
+ * and give each kept element the fresh one's static attributes. Runs before
126
+ * the parts apply, so every dynamic value lands on the live element directly.
127
+ *
128
+ * @param {AdoptionPlan} plan
129
+ * @param {any[]} bound
130
+ */
131
+ export function retargetParts(plan, bound) {
132
+ /** @type {Map<Element, Set<string>>} attributes a part owns, per live element */
133
+ const owned = new Map();
134
+ for (const p of bound) {
135
+ if (p.kind === 'slot') {
136
+ const live = plan.map.get(p.slotEl);
137
+ if (live) p.slotEl = live;
138
+ continue;
139
+ }
140
+ if (!p.el) continue;
141
+ const live = plan.map.get(p.el);
142
+ if (!live) continue;
143
+ // An event part registers its dispatcher in applyPart, with the handler as
144
+ // the options bag (capture / once / passive). This runs before the first
145
+ // apply, so usually nothing is registered yet; move a registration only
146
+ // when one exists, with the SAME options, or a capture listener would be
147
+ // registered twice and a `once` listener would lose its `once`.
148
+ if (p.kind === 'event' && p.handler) {
149
+ p.el.removeEventListener(p.name, p.dispatcher, p.handler);
150
+ live.addEventListener(p.name, p.dispatcher, p.handler);
151
+ }
152
+ p.el = live;
153
+ if (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool') {
154
+ let names = owned.get(live);
155
+ if (!names) owned.set(live, names = new Set());
156
+ names.add(p.name);
157
+ }
158
+ }
159
+ for (const [fresh, live] of [...plan.chains, ...plan.slots]) {
160
+ const isSlot = live.localName === 'slot';
161
+ const names = owned.get(live);
162
+ for (const a of Array.from(live.attributes)) {
163
+ if (fresh.hasAttribute(a.name) || names?.has(a.name)) continue;
164
+ // The SSR projection stamps stay: the slot IS projected, and the owner
165
+ // carrier is what isOwnSlot reads until the owner symbol is stamped.
166
+ if (isSlot && (a.name === PROJECTION_ATTR || a.name === SLOT_OWNER_ATTR)) continue;
167
+ live.removeAttribute(a.name);
168
+ }
169
+ for (const a of Array.from(fresh.attributes)) {
170
+ if (live.getAttribute(a.name) !== a.value) live.setAttribute(a.name, a.value);
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Commit the fresh template into the container around the kept live nodes:
177
+ * every live child that is not kept is removed, every fresh node is inserted
178
+ * in template order, and no kept node moves. The fresh slots' cloned fallback
179
+ * goes to each kept slot's holding fragment.
180
+ *
181
+ * @param {AdoptionPlan} plan
182
+ * @param {Element} container
183
+ * @param {Node[]} top the container's new children, bookends included
184
+ */
185
+ export function commitAdoption(plan, container, top) {
186
+ const mapped = (/** @type {Node} */ n) => plan.map.get(n) || n;
187
+ const state = ensureSlotState(container);
188
+ for (const [fresh, live] of plan.chains) mergeChildren(live, Array.from(fresh.childNodes, mapped));
189
+ mergeChildren(container, top.map(mapped));
190
+ for (const [fresh, live] of plan.slots) {
191
+ const fallback = document.createDocumentFragment();
192
+ while (fresh.firstChild) fallback.appendChild(fresh.firstChild);
193
+ /** @type {any} */ (live)[SLOT_FALLBACK_FRAG] = fallback;
194
+ adoptedSlots.add(live);
195
+ // Forget the adopted snapshot so the first apply reports the initial
196
+ // projection (one slotchange), exactly as the plain swap did.
197
+ state.lastSnapshot.delete(live);
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Make `parent`'s children exactly `want`, removing what is not wanted and
203
+ * inserting the rest, without moving a node already in place in order.
204
+ *
205
+ * @param {Node} parent
206
+ * @param {Node[]} want
207
+ */
208
+ function mergeChildren(parent, want) {
209
+ const keep = new Set(want);
210
+ for (const c of Array.from(parent.childNodes)) {
211
+ if (!keep.has(c)) parent.removeChild(c);
212
+ }
213
+ let cursor = parent.firstChild;
214
+ for (const node of want) {
215
+ if (node === cursor) {
216
+ cursor = cursor.nextSibling;
217
+ continue;
218
+ }
219
+ parent.insertBefore(node, cursor);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The element ancestors of `node` below `root`, nearest first, or null when
225
+ * `root` is not an ancestor.
226
+ *
227
+ * @param {Node} node
228
+ * @param {Node} root
229
+ * @returns {Element[] | null}
230
+ */
231
+ function ancestorsUpTo(node, root) {
232
+ const out = [];
233
+ let p = node.parentNode;
234
+ while (p && p !== root) {
235
+ if (p.nodeType !== 1) return null;
236
+ out.push(/** @type {Element} */ (p));
237
+ p = p.parentNode;
238
+ }
239
+ return p === root ? out : null;
240
+ }
241
+
242
+ /**
243
+ * @param {NodeListOf<ChildNode>} list
244
+ * @param {Node[]} nodes
245
+ */
246
+ function sameNodes(list, nodes) {
247
+ if (list.length !== nodes.length) return false;
248
+ for (let i = 0; i < nodes.length; i++) if (list[i] !== nodes[i]) return false;
249
+ return true;
250
+ }