@wular/pnext 0.0.7 → 0.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@ import { listFiles } from '../utils/fs'
9
9
  import type { BuildManifest, RouteManifestEntry } from '../types'
10
10
 
11
11
  export interface AnalyzeResult {
12
- mode: 'production' | 'development'
13
12
  root: string
14
13
  compression: AnalyzeCompression
15
14
  files: AnalyzeFile[]
@@ -43,6 +42,10 @@ export interface AnalyzeBundleFile {
43
42
  }
44
43
 
45
44
  export interface AnalyzeDynamicBundleFile extends AnalyzeBundleFile {
45
+ /** Island's client-reference id, as emitted in the entry's island table. */
46
+ id: string
47
+ /** Display name: the reference's named export, or its module basename. */
48
+ component: string
46
49
  exportName: string
47
50
  load: 'render' | 'visible'
48
51
  }
@@ -63,11 +66,10 @@ export async function analyzeProject(
63
66
  throw new Error(`No PNext output found at ${config.outPath}. Run pnext build first.`)
64
67
  }
65
68
 
69
+ // Dev output is unminified, so its sizes say nothing about what ships; analyze is production-only.
66
70
  const target = analyzeTarget(config.outPath)
67
71
  if (!target) {
68
- throw new Error(
69
- `No PNext build or dev output found at ${config.outPath}. Run pnext build or pnext dev first.`,
70
- )
72
+ throw new Error(`No production build found at ${config.outPath}. Run pnext build first.`)
71
73
  }
72
74
 
73
75
  const compression = options.compression ?? 'gzip'
@@ -83,13 +85,9 @@ export async function analyzeProject(
83
85
  }
84
86
  }),
85
87
  )
86
- const routeBundles =
87
- target.mode === 'production'
88
- ? await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
89
- : []
88
+ const routeBundles = await analyzeRouteBundles(config.outPath, target.root, rows, options.route)
90
89
 
91
90
  return {
92
- mode: target.mode,
93
91
  root: path.relative(config.root, target.root),
94
92
  compression,
95
93
  files: rows,
@@ -115,7 +113,9 @@ async function analyzeRouteBundles(
115
113
 
116
114
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
117
115
  const pages = manifest.routes.filter(route => route.kind === 'page')
118
- const selected = routeFilter ? filterAnalyzeRoutes(pages, routeFilter) : pages
116
+ const selected: AnalyzeRouteSelection[] = routeFilter
117
+ ? filterAnalyzeRoutes(pages, routeFilter)
118
+ : pages.map(route => ({ route }))
119
119
  if (routeFilter && selected.length === 0) {
120
120
  throw new Error(
121
121
  `No route matches ${routeFilter}. Routes:\n ${pages
@@ -135,22 +135,29 @@ async function analyzeRouteBundles(
135
135
  }
136
136
 
137
137
  const bundles: AnalyzeRouteBundle[] = []
138
- for (const route of selected) {
139
- bundles.push(await analyzeRouteBundle(route, sizes, js))
138
+ for (const { route, pathname } of selected) {
139
+ bundles.push(await analyzeRouteBundle(route, sizes, js, pathname))
140
140
  }
141
141
  return bundles
142
142
  }
143
143
 
144
+ interface AnalyzeRouteSelection {
145
+ route: RouteManifestEntry
146
+ /** Concrete pathname the filter matched, for a param route's prerendered HTML. */
147
+ pathname?: string
148
+ }
149
+
144
150
  // Accepts the route in ':id' or '[id]' template form, or a concrete pathname
145
- // (e.g. /users/ada) matched through the route patterns.
146
- function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string) {
151
+ // (e.g. /users/ada) matched through the route patterns. A concrete pathname is
152
+ // carried through so the bundle reports that page's prerendered HTML.
153
+ function filterAnalyzeRoutes(pages: RouteManifestEntry[], filter: string): AnalyzeRouteSelection[] {
147
154
  const normalized = filter === '/' ? filter : filter.replace(/\/+$/, '')
148
155
  const direct = pages.filter(
149
156
  route => route.route === normalized || publicRoutePath(route.route) === normalized,
150
157
  )
151
- if (direct.length > 0) return direct
158
+ if (direct.length > 0) return direct.map(route => ({ route }))
152
159
  const matched = matchRoute(pages, normalized)
153
- return matched ? [matched.route] : []
160
+ return matched ? [{ route: matched.route, pathname: normalized }] : []
154
161
  }
155
162
 
156
163
  function publicRoutePath(route: string) {
@@ -161,14 +168,30 @@ async function analyzeRouteBundle(
161
168
  route: RouteManifestEntry,
162
169
  sizes: Map<string, AnalyzeFile>,
163
170
  js: (path: string) => Promise<string>,
171
+ pathname?: string,
164
172
  ): Promise<AnalyzeRouteBundle> {
165
173
  const initial = new Set<string>()
166
- addIfExists(initial, sizes, routeHtmlPath(route))
174
+ addIfExists(initial, sizes, routeHtmlPath(route, pathname))
167
175
  addIfExists(initial, sizes, 'assets/global.css')
168
- addIfExists(initial, sizes, `assets/${route.id}.css`)
176
+ // Mirrors the renderer's stylesheet list: compat cssChunking splits a route's
177
+ // CSS into `<id>-<n>.css` and records them in cssAssets; `<id>.css` is emitted
178
+ // only when it is unset.
179
+ for (const asset of route.cssAssets ?? (route.cssImports.length ? [`${route.id}.css`] : []))
180
+ addIfExists(initial, sizes, `assets/${asset}`)
181
+ // Every compat page loads this with a blocking <script>. The sibling
182
+ // `_ssgManifest.js` (router-fetched, never in the document) and the
183
+ // `polyfills-*.js` chunk (noModule: legacy browsers only) are deliberately
184
+ // not initial weight for a modern client.
185
+ addIfExists(initial, sizes, '_next/static/pnext/_buildManifest.js')
169
186
 
170
187
  if (route.clientEntry) {
171
188
  addIfExists(initial, sizes, route.clientEntry)
189
+ // The build records the entry's chunk closure from the esbuild metafile and
190
+ // the renderer modulepreloads exactly this set. Chunk folding hoists imports
191
+ // out of the entry module, so walking its source alone under-reports; the
192
+ // walk stays as a union for dev-style single-bundle entries, which never
193
+ // populate clientEntryImports.
194
+ for (const asset of route.clientEntryImports ?? []) addIfExists(initial, sizes, asset)
172
195
  for (const dependency of await staticDependencies(route.clientEntry, sizes, js))
173
196
  initial.add(dependency)
174
197
  }
@@ -180,17 +203,21 @@ async function analyzeRouteBundle(
180
203
 
181
204
  if (route.clientEntry && sizes.has(toPosix(route.clientEntry))) {
182
205
  const entrySource = await js(route.clientEntry)
183
- const imports = dynamicImports(entrySource, route.clientEntry)
184
- for (const imported of imports) {
185
- const reference = route.clientReferences.find(item => item.exportName === imported.exportName)
206
+ for (const island of islandImports(entrySource, route.clientEntry)) {
207
+ const row = sizes.get(island.path)
208
+ if (!row) continue
209
+ // Keyed by island id: every island's loader resolves `module.default`, so
210
+ // matching on the export name collapses them all onto one reference.
211
+ const reference = route.clientReferences.find(item => item.id === island.id)
186
212
  const load: AnalyzeDynamicBundleFile['load'] =
187
213
  reference?.dynamic?.load === 'visible' ? 'visible' : 'render'
188
- const row = sizes.get(imported.path)
189
- if (!row) continue
190
- dynamicTargets.add(imported.path)
214
+ dynamicTargets.add(island.path)
215
+ const exportName = reference?.exportName ?? 'default'
191
216
  const item: AnalyzeDynamicBundleFile = {
192
- path: imported.path,
193
- exportName: imported.exportName,
217
+ path: island.path,
218
+ id: island.id,
219
+ component: islandComponent(exportName, reference?.file, island.path),
220
+ exportName,
194
221
  load,
195
222
  rawBytes: row.rawBytes,
196
223
  compressedBytes: row.compressedBytes,
@@ -198,12 +225,22 @@ async function analyzeRouteBundle(
198
225
  if (load === 'visible') visibleDynamic.push(item)
199
226
  else dynamic.push(item)
200
227
 
201
- for (const dependency of await staticDependencies(imported.path, sizes, js)) {
228
+ // An island with its own CSS loads `assets/<referenceId>.css` alongside its
229
+ // chunk (loadIslandCss); it is deferred weight, so it belongs here rather
230
+ // than in initial.
231
+ if (reference?.cssImports?.length) addIfExists(lazyShared, sizes, `assets/${island.id}.css`)
232
+ for (const dependency of await staticDependencies(island.path, sizes, js)) {
202
233
  if (!initial.has(dependency) && !dynamicTargets.has(dependency)) lazyShared.add(dependency)
203
234
  }
204
235
  }
205
236
 
206
- for (const importedPath of dynamicImportPaths(entrySource, route.clientEntry)) {
237
+ // Same metafile closure as the renderer's low-priority preloads, unioned
238
+ // with the entry-source walk for the same reason as the static side above.
239
+ for (const asset of [
240
+ ...(route.clientDynamicImports ?? []),
241
+ ...dynamicImportPaths(entrySource, route.clientEntry),
242
+ ]) {
243
+ const importedPath = toPosix(asset)
207
244
  if (initial.has(importedPath) || dynamicTargets.has(importedPath)) continue
208
245
  if (sizes.has(importedPath)) lazyShared.add(importedPath)
209
246
  for (const dependency of await staticDependencies(importedPath, sizes, js)) {
@@ -222,6 +259,12 @@ async function analyzeRouteBundle(
222
259
  }
223
260
  }
224
261
 
262
+ function islandComponent(exportName: string, file: string | undefined, chunkPath: string) {
263
+ if (exportName !== 'default') return exportName
264
+ const source = file ?? chunkPath.replace(/-[A-Z0-9]{8}\.js$/, '.js')
265
+ return path.posix.basename(toPosix(source)).replace(/\.[^.]+$/, '')
266
+ }
267
+
225
268
  async function staticDependencies(
226
269
  entry: string,
227
270
  sizes: Map<string, AnalyzeFile>,
@@ -244,17 +287,22 @@ async function staticDependencies(
244
287
  return dependencies
245
288
  }
246
289
 
247
- function dynamicImports(source: string, from: string) {
248
- const imports: { path: string; exportName: string }[] = []
290
+ // Entries of the entry module's island table, as emitted by client/entry.ts:
291
+ // { id: "c-...", options: {...}, load: () => import("./chunk.js").then(m => m.default) }
292
+ // The CSS variant wraps the import in `Promise.all([...])`. Statically-bundled
293
+ // islands carry `Component:` instead of `load:` and are part of the entry's own
294
+ // closure, so they are not matched here.
295
+ function islandImports(source: string, from: string) {
296
+ const imports: { path: string; id: string }[] = []
249
297
  const pattern =
250
- /import\("([^"]+)"\)\.then\([A-Za-z_$][\w$]*=>[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)\)/g
298
+ /\bid\s*:\s*"([^"]+)"\s*,\s*options\s*:\s*\{[^{}]*\}\s*,\s*load\s*:\s*\(\)\s*=>\s*(?:Promise\.all\(\[\s*)?import\(\s*"([^"]+)"\s*\)/g
251
299
  let match: RegExpExecArray | null
252
300
  while ((match = pattern.exec(source))) {
253
- const specifier = match[1]
254
- const exportName = match[2]
255
- if (!specifier || !exportName) continue
301
+ const id = match[1]
302
+ const specifier = match[2]
303
+ if (!id || !specifier) continue
256
304
  const resolved = resolveBuiltImport(from, specifier)
257
- if (resolved) imports.push({ path: resolved, exportName })
305
+ if (resolved) imports.push({ path: resolved, id })
258
306
  }
259
307
  return imports
260
308
  }
@@ -306,18 +354,27 @@ function bundleFiles(paths: string[], sizes: Map<string, AnalyzeFile>) {
306
354
  .sort((a, b) => b.compressedBytes - a.compressedBytes)
307
355
  }
308
356
 
309
- function routeHtmlPath(route: RouteManifestEntry) {
310
- return route.route === '/' ? 'index.html' : `${route.route.replace(/^\/+/, '')}/index.html`
357
+ function routeHtmlPath(route: RouteManifestEntry, pathname?: string) {
358
+ const target = pathname ?? prerenderedPathname(route) ?? route.route
359
+ return target === '/' ? 'index.html' : `${target.replace(/^\/+/, '')}/index.html`
360
+ }
361
+
362
+ // A param route prerenders one file per param set, never `posts/:slug/index.html`.
363
+ // Report the first as the representative page instead of dropping HTML entirely.
364
+ function prerenderedPathname(route: RouteManifestEntry) {
365
+ const params = route.prerenderedParams?.[0]
366
+ if (!params) return undefined
367
+ return route.route.replace(/:([a-zA-Z0-9_]+)\*?/g, (_match, name: string) => {
368
+ const value = params[name]
369
+ return Array.isArray(value) ? value.join('/') : (value ?? '')
370
+ })
311
371
  }
312
372
 
313
373
  function analyzeTarget(outPath: string) {
374
+ // PPR shells live in `.pnext/ppr`, outside `public`: they are resumed
375
+ // server-side and never downloaded, so they are not shipped weight.
314
376
  const publicPath = path.join(outPath, 'public')
315
- if (existsSync(publicPath)) return { mode: 'production' as const, root: publicPath }
316
-
317
- const cachePath = path.join(outPath, 'cache')
318
- if (existsSync(cachePath)) return { mode: 'development' as const, root: cachePath }
319
-
320
- return null
377
+ return existsSync(publicPath) ? { root: publicPath } : null
321
378
  }
322
379
 
323
380
  function compressedSize(bytes: Buffer, compression: AnalyzeCompression) {
@@ -344,9 +401,7 @@ export function printAnalyzeResult(
344
401
  result: AnalyzeResult,
345
402
  options: { route?: string; files: boolean },
346
403
  ) {
347
- console.log(
348
- `PNext analyze: ${result.mode === 'production' ? 'production build' : 'dev server cache'} at ${dim(result.root)} (${result.compression})`,
349
- )
404
+ console.log(`PNext analyze: production build at ${dim(result.root)} (${result.compression})`)
350
405
  printRouteBundles(result)
351
406
  // With a route filter, the remaining files are mostly other routes' bundles —
352
407
  // listing them as "other" would misread as unowned weight.
@@ -385,11 +440,11 @@ function printDynamicBundleGroup(
385
440
  if (files.length === 0) return
386
441
  console.log(`\n ${bold(label)}`)
387
442
  const columns = rowColumns(files)
388
- const componentWidth = Math.max(...files.map(file => file.exportName.length))
443
+ const componentWidth = Math.max(...files.map(file => file.component.length))
389
444
  const pathWidth = Math.max(...files.map(file => file.path.length))
390
445
  for (const file of files) {
391
446
  console.log(
392
- ` ${file.exportName.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
447
+ ` ${file.component.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
393
448
  )
394
449
  }
395
450
  }
@@ -91,7 +91,9 @@ function islandContextModulePath() {
91
91
  // Wire-marker revival shared with the server encoder (utils/serialize.ts):
92
92
  // island props carrying a CYCLE travel as `$$pnext_ref` back-references.
93
93
  function staticSlotsModulePath() {
94
- return path.join(import.meta.dirname, '../render/static-slots.ts')
94
+ // The preact-free half: entries import it eagerly, so it must not drag preact into a
95
+ // visible-dynamic entry's static graph.
96
+ return path.join(import.meta.dirname, '../render/static-slots-revive.ts')
95
97
  }
96
98
 
97
99
  function serializeModulePath() {
@@ -296,10 +298,12 @@ import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots
296
298
  // Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
297
299
  // inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
298
300
  // with no element props skip the walk on a substring test of the raw attribute.
299
- function islandProps(raw, root, toChildren) {
301
+ // \`h\` is threaded in rather than imported by the slot reviver: that would put preact in the static
302
+ // graph of entries (visible-dynamic) that otherwise only lazy-import it.
303
+ function islandProps(raw, root, toChildren, h) {
300
304
  const props = parseIslandProps(raw);
301
305
  if (!__pnextHasIslandSlots(raw)) return props;
302
- return __pnextReviveIslandSlots(props, root, toChildren);
306
+ return __pnextReviveIslandSlots(props, root, toChildren, h);
303
307
  }
304
308
  function parseIslandProps(raw) {
305
309
  const props = JSON.parse(raw || '{}');${
@@ -978,7 +982,7 @@ async function mountIslandTree(root, island) {
978
982
  ]);
979
983
  ${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
980
984
  const source = preservedSource(root, render);
981
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
985
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
982
986
  const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
983
987
  if (source !== root) adoptPreserved(render, root, wrapped);
984
988
  else mount(hydrate, render, root, wrapped);
@@ -991,7 +995,7 @@ async function mountIslandTree(root, island) {
991
995
  const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), island.load()]);
992
996
  const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
993
997
  const source = preservedSource(root, render);
994
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node)), await staticChildren(h, source, island.id));
998
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
995
999
  if (source !== root) adoptPreserved(render, root, vnode);
996
1000
  else mount(hydrate, render, root, vnode);
997
1001
  }`
@@ -1077,7 +1081,7 @@ async function domNode(h, node) {
1077
1081
  if (!island) return h(element.localName, domProps(element), await domChildren(h, element));
1078
1082
  const Component = island.Component ?? await island.load();
1079
1083
  const rawProps = element.getAttribute('data-pnext-props') ?? '{}';
1080
- const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node)), await staticChildren(h, element, island.id));
1084
+ const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node), h), await staticChildren(h, element, island.id));
1081
1085
  return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
1082
1086
  }
1083
1087
 
@@ -1364,7 +1368,7 @@ async function mountIslandTree(root, island) {
1364
1368
  // Preserved across a soft navigation: re-render in place with the incoming
1365
1369
  // document's props/children so component state survives while the routed
1366
1370
  // content under the island updates.
1367
- const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
1371
+ const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
1368
1372
  render(${wrapInBoundary('vnode', nextCompat)}, root);
1369
1373
  pnextMountedRoots.add(root);
1370
1374
  return;
@@ -1375,7 +1379,7 @@ async function mountIslandTree(root, island) {
1375
1379
  root.replaceChildren(...incoming.childNodes);
1376
1380
  root.__pnextLive = undefined;
1377
1381
  }
1378
- const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
1382
+ const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
1379
1383
  mount(root, ${wrapInBoundary('vnode', nextCompat)});
1380
1384
  }
1381
1385
 
@@ -1448,7 +1452,7 @@ async function domNode(node) {
1448
1452
  : ''
1449
1453
  }
1450
1454
  const Component = island.Component ?? await island.load();
1451
- return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren)', 'children', 'element', nextCompat), nextCompat)};
1455
+ return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element', nextCompat), nextCompat)};
1452
1456
  }
1453
1457
 
1454
1458
  if (Page && element.id === 'pnext-page') {
@@ -0,0 +1,65 @@
1
+ import type { h, VNode } from 'preact'
2
+
3
+ // Client half of the static-slot protocol, split out of ./static-slots so it stays PREACT-FREE:
4
+ // entries import it eagerly, and a visible-dynamic entry must not pull preact into its static graph
5
+ // (preact declares no `sideEffects: false`, so even an unused binding keeps the chunk import alive).
6
+ // `createElement` is therefore threaded in from the island mount, where preact is lazily imported.
7
+
8
+ export const ISLAND_STATIC_SLOT_ATTRIBUTE = 'data-pnext-static-slot'
9
+ export const ISLAND_STATIC_SLOT_MARKER = '$$pnext_slot'
10
+
11
+ type Props = Record<string, unknown>
12
+
13
+ /** Cheap gate on the raw props attribute so islands with no element props skip the revive walk. */
14
+ export function hasIslandStaticSlots(raw: string) {
15
+ return raw.includes(ISLAND_STATIC_SLOT_MARKER)
16
+ }
17
+
18
+ /**
19
+ * Client mount: swap each `$$pnext_slot` marker for a `pnext-static-slot` host holding the matching
20
+ * server-rendered DOM, converted to vnodes by the entry's DOM walker (so nested islands inside the
21
+ * adopted subtree become real island vnodes and hydrate on their own). The content is static server
22
+ * markup - it never re-renders, same as element children.
23
+ */
24
+ export async function reviveIslandStaticSlots(
25
+ props: Props,
26
+ root: ParentNode,
27
+ toChildren: (node: ParentNode) => unknown,
28
+ createElement: typeof h,
29
+ ): Promise<Props> {
30
+ return (await reviveSlots(props, root, toChildren, createElement, new Set())) as Props
31
+ }
32
+
33
+ async function reviveSlots(
34
+ value: unknown,
35
+ root: ParentNode,
36
+ toChildren: (node: ParentNode) => unknown,
37
+ createElement: typeof h,
38
+ seen: Set<object>,
39
+ ): Promise<unknown> {
40
+ if (value === null || typeof value !== 'object' || seen.has(value)) return value
41
+ const marker = (value as Props)[ISLAND_STATIC_SLOT_MARKER]
42
+ if (typeof marker === 'string') {
43
+ const node = root.querySelector(`[${ISLAND_STATIC_SLOT_ATTRIBUTE}="${cssEscape(marker)}"]`)
44
+ // No server markup for this slot (the island never rendered the prop, or it was skipped for
45
+ // SSR): nothing to adopt, so the prop arrives null rather than as an empty host.
46
+ if (!node) return null
47
+ return createElement(
48
+ 'pnext-static-slot',
49
+ { [ISLAND_STATIC_SLOT_ATTRIBUTE]: marker, style: { display: 'contents' } },
50
+ (await toChildren(node)) as VNode,
51
+ )
52
+ }
53
+ const proto = Object.getPrototypeOf(value) as object | null
54
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
55
+ seen.add(value)
56
+ const target = value as Props
57
+ for (const key of Object.keys(target)) {
58
+ target[key] = await reviveSlots(target[key], root, toChildren, createElement, seen)
59
+ }
60
+ return value
61
+ }
62
+
63
+ function cssEscape(value: string) {
64
+ return value.replace(/["\\]/g, '\\$&')
65
+ }
@@ -1,8 +1,9 @@
1
1
  import { h, type VNode } from 'preact'
2
2
  import { isElementLike } from '../utils/serialize'
3
+ import { ISLAND_STATIC_SLOT_ATTRIBUTE, ISLAND_STATIC_SLOT_MARKER } from './static-slots-revive'
3
4
 
4
- export const ISLAND_STATIC_SLOT_ATTRIBUTE = 'data-pnext-static-slot'
5
- export const ISLAND_STATIC_SLOT_MARKER = '$$pnext_slot'
5
+ // The client half lives in ./static-slots-revive (preact-free); re-exported so importers keep one entry point.
6
+ export * from './static-slots-revive'
6
7
 
7
8
  type Props = Record<string, unknown>
8
9
 
@@ -52,55 +53,3 @@ function mapSlots(
52
53
  if (Array.isArray(value)) return mapped.map(([, item]) => item)
53
54
  return Object.fromEntries(mapped)
54
55
  }
55
-
56
- /** Cheap gate on the raw props attribute so islands with no element props skip the revive walk. */
57
- export function hasIslandStaticSlots(raw: string) {
58
- return raw.includes(ISLAND_STATIC_SLOT_MARKER)
59
- }
60
-
61
- /**
62
- * Client mount: swap each `$$pnext_slot` marker for a `pnext-static-slot` host holding the matching
63
- * server-rendered DOM, converted to vnodes by the entry's DOM walker (so nested islands inside the
64
- * adopted subtree become real island vnodes and hydrate on their own). The content is static server
65
- * markup - it never re-renders, same as element children.
66
- */
67
- export async function reviveIslandStaticSlots(
68
- props: Props,
69
- root: ParentNode,
70
- toChildren: (node: ParentNode) => unknown,
71
- ): Promise<Props> {
72
- return (await reviveSlots(props, root, toChildren, new Set())) as Props
73
- }
74
-
75
- async function reviveSlots(
76
- value: unknown,
77
- root: ParentNode,
78
- toChildren: (node: ParentNode) => unknown,
79
- seen: Set<object>,
80
- ): Promise<unknown> {
81
- if (value === null || typeof value !== 'object' || seen.has(value)) return value
82
- const marker = (value as Props)[ISLAND_STATIC_SLOT_MARKER]
83
- if (typeof marker === 'string') {
84
- const node = root.querySelector(`[${ISLAND_STATIC_SLOT_ATTRIBUTE}="${cssEscape(marker)}"]`)
85
- // No server markup for this slot (the island never rendered the prop, or it was skipped for
86
- // SSR): nothing to adopt, so the prop arrives null rather than as an empty host.
87
- if (!node) return null
88
- return h(
89
- 'pnext-static-slot',
90
- { [ISLAND_STATIC_SLOT_ATTRIBUTE]: marker, style: { display: 'contents' } },
91
- (await toChildren(node)) as VNode,
92
- )
93
- }
94
- const proto = Object.getPrototypeOf(value) as object | null
95
- if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value
96
- seen.add(value)
97
- const target = value as Props
98
- for (const key of Object.keys(target)) {
99
- target[key] = await reviveSlots(target[key], root, toChildren, seen)
100
- }
101
- return value
102
- }
103
-
104
- function cssEscape(value: string) {
105
- return value.replace(/["\\]/g, '\\$&')
106
- }