@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 +1 -1
- package/src/cli/analyze.ts +103 -48
- package/src/client/entry.ts +13 -9
- package/src/render/static-slots-revive.ts +65 -0
- package/src/render/static-slots.ts +3 -54
package/package.json
CHANGED
package/src/cli/analyze.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
dynamicTargets.add(imported.path)
|
|
214
|
+
dynamicTargets.add(island.path)
|
|
215
|
+
const exportName = reference?.exportName ?? 'default'
|
|
191
216
|
const item: AnalyzeDynamicBundleFile = {
|
|
192
|
-
path:
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
|
254
|
-
const
|
|
255
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
447
|
+
` ${file.component.padEnd(componentWidth)} ${dim(file.path.padEnd(pathWidth))} ${formatFileSize(file, compression, columns)}`,
|
|
393
448
|
)
|
|
394
449
|
}
|
|
395
450
|
}
|
package/src/client/entry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
export
|
|
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
|
-
}
|