@wular/pnext 0.0.8 → 0.0.9
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/dev.ts +5 -1
- package/src/client/build.ts +94 -119
- package/src/client/entry.ts +74 -57
- package/src/compat/next/font/runtime.ts +3 -6
- package/src/dev/server.ts +27 -54
- package/src/render/renderer.ts +61 -16
- package/src/render/slots.tsx +6 -12
- package/src/utils/serialize.ts +59 -2
package/package.json
CHANGED
package/src/cli/dev.ts
CHANGED
|
@@ -86,7 +86,11 @@ function watchServerMemory(server: DevServerHandle) {
|
|
|
86
86
|
if (!Number.isFinite(limitMb) || limitMb <= 0) return
|
|
87
87
|
const limitBytes = limitMb * 1024 * 1024
|
|
88
88
|
const timer = setInterval(() => {
|
|
89
|
-
|
|
89
|
+
// Bun (<=1.3.x) lacks the `process.memoryUsage.rss` fast path Node provides.
|
|
90
|
+
const rss =
|
|
91
|
+
typeof process.memoryUsage.rss === 'function'
|
|
92
|
+
? process.memoryUsage.rss()
|
|
93
|
+
: process.memoryUsage().rss
|
|
90
94
|
if (rss < limitBytes) return
|
|
91
95
|
clearInterval(timer)
|
|
92
96
|
const usedMb = Math.round(rss / (1024 * 1024))
|
package/src/client/build.ts
CHANGED
|
@@ -35,8 +35,6 @@ import {
|
|
|
35
35
|
} from '../resolve/imports'
|
|
36
36
|
import {
|
|
37
37
|
CLIENT_RUNTIME_MODULE,
|
|
38
|
-
DYN_SHARED_GLOBAL,
|
|
39
|
-
dynSharedSpecifiers,
|
|
40
38
|
clientEntrySource,
|
|
41
39
|
clientRuntimeFacts,
|
|
42
40
|
clientRuntimeSource,
|
|
@@ -369,23 +367,60 @@ async function preparePrebuilt(
|
|
|
369
367
|
}
|
|
370
368
|
}
|
|
371
369
|
|
|
372
|
-
/**
|
|
373
|
-
|
|
374
|
-
|
|
370
|
+
/**
|
|
371
|
+
* The served URL of a deferred dynamic reference's on-demand output (dev split).
|
|
372
|
+
* `r` names the route whose build emitted it: two routes reaching the same island
|
|
373
|
+
* each bundle their own, and one route's copy chunk-splits against its own entry.
|
|
374
|
+
*/
|
|
375
|
+
export function deferredDynamicChunkHref(reference: Pick<ClientReference, 'id'>, routeId?: string) {
|
|
376
|
+
const query = routeId ? `?r=${encodeURIComponent(routeId)}` : ''
|
|
377
|
+
return `/__pnext/client-dyn/${reference.id}.js${query}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Where the dev server serves this build's chunks from (see publicPath). */
|
|
381
|
+
export const devClientPublicPath = '/__pnext/client'
|
|
382
|
+
|
|
383
|
+
export interface DeferredDynamicEntry {
|
|
384
|
+
id: string
|
|
385
|
+
file: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Every deferred reference this route's build must emit an output for: the ones
|
|
390
|
+
* the route scan named, plus the ones its own pipeline rewrite registered.
|
|
391
|
+
*/
|
|
392
|
+
function deferredDynamicEntries(route: RouteManifestEntry): DeferredDynamicEntry[] {
|
|
393
|
+
const entries = new Map<string, string>()
|
|
394
|
+
for (const reference of route.clientReferences) {
|
|
395
|
+
if (reference.dynamic && !ssrClientReference(reference)) {
|
|
396
|
+
entries.set(reference.id, reference.file)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const [id, ref] of deferredDynamicRouteRefs.get(route.id) ?? []) entries.set(id, ref.file)
|
|
400
|
+
return [...entries].map(([id, file]) => ({ id, file }))
|
|
375
401
|
}
|
|
376
402
|
|
|
377
403
|
export interface DeferredDynamicRef {
|
|
378
404
|
file: string
|
|
379
405
|
exportName: string
|
|
406
|
+
/** Entry that rewrote this reference: the build its output is emitted by. */
|
|
407
|
+
routeId?: string
|
|
380
408
|
}
|
|
381
409
|
|
|
382
410
|
// Process-level registry the dev chunk endpoint resolves ids through. Entries
|
|
383
411
|
// come from the pipeline rewrite below and from each out-dir's sidecar (a
|
|
384
412
|
// restart serving a cached entry never re-ran the rewrite).
|
|
385
413
|
const deferredDynamicRefs = new Map<string, DeferredDynamicRef>()
|
|
414
|
+
// Indexed by route as well: a route's build needs every reference it reached as
|
|
415
|
+
// an entry point, and a rewrite only names them once the build has run.
|
|
416
|
+
const deferredDynamicRouteRefs = new Map<string, Map<string, DeferredDynamicRef>>()
|
|
386
417
|
|
|
387
418
|
export function registerDeferredDynamicRef(id: string, ref: DeferredDynamicRef) {
|
|
388
419
|
deferredDynamicRefs.set(id, ref)
|
|
420
|
+
if (!ref.routeId) return
|
|
421
|
+
const refs = deferredDynamicRouteRefs.get(ref.routeId) ?? new Map<string, DeferredDynamicRef>()
|
|
422
|
+
deferredDynamicRouteRefs.set(ref.routeId, refs)
|
|
423
|
+
refs.set(id, ref)
|
|
389
424
|
}
|
|
390
425
|
|
|
391
426
|
export function deferredDynamicRefById(id: string) {
|
|
@@ -396,11 +431,11 @@ export function deferredDynamicRefById(id: string) {
|
|
|
396
431
|
export const DEFERRED_DYNAMIC_SIDECAR = 'dyn-refs.json'
|
|
397
432
|
|
|
398
433
|
/** Dev-only: deferred dynamic references load from the on-demand chunk endpoint. */
|
|
399
|
-
function devDeferredDynamicHref(dev: boolean | undefined) {
|
|
434
|
+
function devDeferredDynamicHref(dev: boolean | undefined, routeId?: string) {
|
|
400
435
|
if (!dev || !devDynamicSplitEnabled()) return undefined
|
|
401
436
|
return (reference: ClientReference) =>
|
|
402
437
|
reference.dynamic && !ssrClientReference(reference)
|
|
403
|
-
? deferredDynamicChunkHref(reference)
|
|
438
|
+
? deferredDynamicChunkHref(reference, routeId)
|
|
404
439
|
: undefined
|
|
405
440
|
}
|
|
406
441
|
|
|
@@ -422,7 +457,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
422
457
|
await ensureDir(outDir)
|
|
423
458
|
const suspense = routeSuspenseFree(config, route) === false
|
|
424
459
|
const source = clientEntrySource({
|
|
425
|
-
deferredDynamicHref: devDeferredDynamicHref(dev),
|
|
460
|
+
deferredDynamicHref: devDeferredDynamicHref(dev, route.id),
|
|
426
461
|
pageFile: route.client ? route.file : undefined,
|
|
427
462
|
clientReferences: route.clientReferences,
|
|
428
463
|
nextCompat: nextCompatEnabled(config),
|
|
@@ -439,7 +474,7 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
439
474
|
})
|
|
440
475
|
const entryName = clientEntryName(route)
|
|
441
476
|
const outfile = path.join(outDir, `${entryName}.js`)
|
|
442
|
-
const pipeline = createClientSourcePipeline(config, dev === true)
|
|
477
|
+
const pipeline = createClientSourcePipeline(config, dev === true, route.id)
|
|
443
478
|
pipeline.warmRoutes([route])
|
|
444
479
|
const runtimeFacts = clientRuntimeFacts(
|
|
445
480
|
[
|
|
@@ -467,38 +502,59 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
467
502
|
),
|
|
468
503
|
)
|
|
469
504
|
|
|
505
|
+
const split = Boolean(devDeferredDynamicHref(dev))
|
|
506
|
+
const runEntryBuild = (dynEntries: DeferredDynamicEntry[]) =>
|
|
507
|
+
build({
|
|
508
|
+
...baseClientBuildOptions(config, dev),
|
|
509
|
+
// Every deferred reference is an entry point of THIS build, so esbuild
|
|
510
|
+
// hoists what it shares with the route entry into a shared chunk. Module
|
|
511
|
+
// identity — contexts, singletons, the preact instance — then holds by
|
|
512
|
+
// construction, and esbuild owns the interop it always did.
|
|
513
|
+
entryPoints: [
|
|
514
|
+
{ in: virtualEntryPath(route.id), out: entryName },
|
|
515
|
+
...dynEntries.map(entry => ({ in: entry.file, out: entry.id })),
|
|
516
|
+
],
|
|
517
|
+
// Chunk imports as absolute URLs: an on-demand output is served from a
|
|
518
|
+
// different directory than the entry, and a relative specifier would make
|
|
519
|
+
// the browser fetch the same chunk under two URLs — two module instances.
|
|
520
|
+
...(split ? { publicPath: devClientPublicPath } : {}),
|
|
521
|
+
outdir: outDir,
|
|
522
|
+
metafile: true,
|
|
523
|
+
plugins: clientBuildPlugins(
|
|
524
|
+
config,
|
|
525
|
+
pipeline,
|
|
526
|
+
[
|
|
527
|
+
...(prebuilt ? [prebuilt.plugin] : []),
|
|
528
|
+
virtualEntryPlugin([{ route, source }]),
|
|
529
|
+
...(split ? [deferredDynamicExternalPlugin()] : []),
|
|
530
|
+
clientRuntimePlugin(runtimeFacts),
|
|
531
|
+
],
|
|
532
|
+
!suspense,
|
|
533
|
+
),
|
|
534
|
+
})
|
|
535
|
+
|
|
470
536
|
let metafile: Metafile | undefined
|
|
537
|
+
let dynEntries = split ? deferredDynamicEntries(route) : []
|
|
471
538
|
try {
|
|
472
539
|
const result = await profileClientBuild(route, dev, () =>
|
|
473
|
-
clientProfile.timeAsync('esbuild', () =>
|
|
474
|
-
build({
|
|
475
|
-
...baseClientBuildOptions(config, dev),
|
|
476
|
-
stdin: {
|
|
477
|
-
contents: source,
|
|
478
|
-
loader: 'ts',
|
|
479
|
-
resolveDir: process.cwd(),
|
|
480
|
-
sourcefile: `${route.id}.ts`,
|
|
481
|
-
},
|
|
482
|
-
outdir: outDir,
|
|
483
|
-
entryNames: entryName,
|
|
484
|
-
metafile: true,
|
|
485
|
-
plugins: clientBuildPlugins(
|
|
486
|
-
config,
|
|
487
|
-
pipeline,
|
|
488
|
-
[
|
|
489
|
-
...(prebuilt ? [prebuilt.plugin] : []),
|
|
490
|
-
...(devDeferredDynamicHref(dev) ? [deferredDynamicExternalPlugin()] : []),
|
|
491
|
-
clientRuntimePlugin(runtimeFacts),
|
|
492
|
-
],
|
|
493
|
-
!suspense,
|
|
494
|
-
),
|
|
495
|
-
}),
|
|
496
|
-
),
|
|
540
|
+
clientProfile.timeAsync('esbuild', () => runEntryBuild(dynEntries)),
|
|
497
541
|
)
|
|
498
542
|
metafile = result.metafile
|
|
499
543
|
} catch (error) {
|
|
500
544
|
throw withClientImportTrace(error, config, route, source)
|
|
501
545
|
}
|
|
546
|
+
// A dynamic() inside a 'use client' module is only named by the pipeline's
|
|
547
|
+
// rewrite, which runs mid-build: those references become entry points on the
|
|
548
|
+
// rebuild here, and stay ones from then on (the registry outlives the build).
|
|
549
|
+
if (split) {
|
|
550
|
+
const discovered = deferredDynamicEntries(route)
|
|
551
|
+
if (discovered.length !== dynEntries.length) {
|
|
552
|
+
dynEntries = discovered
|
|
553
|
+
metafile = (
|
|
554
|
+
await clientProfile.timeAsync('esbuildDynEntries', () => runEntryBuild(dynEntries))
|
|
555
|
+
).metafile
|
|
556
|
+
}
|
|
557
|
+
}
|
|
502
558
|
await clientProfile.timeAsync('prebuiltSettle', () => prebuilt?.settle() ?? Promise.resolve())
|
|
503
559
|
|
|
504
560
|
if (metafile) {
|
|
@@ -522,88 +578,6 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
522
578
|
return outfile
|
|
523
579
|
}
|
|
524
580
|
|
|
525
|
-
/**
|
|
526
|
-
* Dev split: bundle ONE deferred dynamic reference on browser demand. The
|
|
527
|
-
* chunk is its own esbuild build, so single-instance vendors (preact and its
|
|
528
|
-
* facades) resolve to shared shims reading the entry's published namespaces
|
|
529
|
-
* (see dynSharedTableSource) instead of bundling a second copy whose hooks
|
|
530
|
-
* would never see the entry renderer's dispatch.
|
|
531
|
-
*/
|
|
532
|
-
export async function buildClientDynamicChunk({
|
|
533
|
-
config,
|
|
534
|
-
route,
|
|
535
|
-
reference,
|
|
536
|
-
outDir,
|
|
537
|
-
}: {
|
|
538
|
-
config: ResolvedConfig
|
|
539
|
-
route?: RouteManifestEntry
|
|
540
|
-
reference: DeferredDynamicRef & { id: string }
|
|
541
|
-
outDir: string
|
|
542
|
-
}) {
|
|
543
|
-
await ensureDir(outDir)
|
|
544
|
-
const pipeline = createClientSourcePipeline(config, true)
|
|
545
|
-
const prebuilt = await preparePrebuilt(
|
|
546
|
-
config,
|
|
547
|
-
outDir,
|
|
548
|
-
true,
|
|
549
|
-
importer => pipeline.sourceOf(importer) ?? readTextSyncSafe(importer),
|
|
550
|
-
route ? surfaceSignature(config, route, true) : '',
|
|
551
|
-
)
|
|
552
|
-
const source = [
|
|
553
|
-
reference.exportName === 'default'
|
|
554
|
-
? `export { default } from ${JSON.stringify(reference.file)};`
|
|
555
|
-
: '',
|
|
556
|
-
`export * from ${JSON.stringify(reference.file)};`,
|
|
557
|
-
].join('\n')
|
|
558
|
-
const outfile = path.join(outDir, `${reference.id}.js`)
|
|
559
|
-
await clientProfile.timeAsync('dynChunk', () =>
|
|
560
|
-
build({
|
|
561
|
-
...baseClientBuildOptions(config, true),
|
|
562
|
-
stdin: {
|
|
563
|
-
contents: source,
|
|
564
|
-
loader: 'ts',
|
|
565
|
-
resolveDir: process.cwd(),
|
|
566
|
-
sourcefile: `${reference.id}.dyn.ts`,
|
|
567
|
-
},
|
|
568
|
-
outdir: outDir,
|
|
569
|
-
entryNames: reference.id,
|
|
570
|
-
plugins: clientBuildPlugins(config, pipeline, [
|
|
571
|
-
...(prebuilt ? [prebuilt.plugin] : []),
|
|
572
|
-
dynSharedVendorPlugin(nextCompatEnabled(config)),
|
|
573
|
-
deferredDynamicExternalPlugin(),
|
|
574
|
-
]),
|
|
575
|
-
}),
|
|
576
|
-
)
|
|
577
|
-
await prebuilt?.settle()
|
|
578
|
-
clientProfile.report(`client dynamic chunk ${reference.id}`)
|
|
579
|
-
return outfile
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/** Shared-vendor shims: preact resolves to the entry's published namespace. */
|
|
583
|
-
function dynSharedVendorPlugin(nextCompat: boolean): Plugin {
|
|
584
|
-
const namespace = 'pnext-dyn-shared'
|
|
585
|
-
const filter = new RegExp(`^(?:${dynSharedSpecifiers(nextCompat).map(escapeRegex).join('|')})$`)
|
|
586
|
-
return {
|
|
587
|
-
name: 'pnext-dyn-shared-vendor',
|
|
588
|
-
setup(build) {
|
|
589
|
-
build.onResolve({ filter }, args =>
|
|
590
|
-
args.namespace === namespace ? undefined : { path: args.path, namespace },
|
|
591
|
-
)
|
|
592
|
-
build.onLoad({ filter: /.*/, namespace }, async args => {
|
|
593
|
-
const names = Object.keys((await import(args.path)) as Record<string, unknown>).filter(
|
|
594
|
-
name => /^[A-Za-z_$][\w$]*$/.test(name) && name !== 'default',
|
|
595
|
-
)
|
|
596
|
-
const lines = [
|
|
597
|
-
`const m = window.${DYN_SHARED_GLOBAL}[${JSON.stringify(args.path)}];`,
|
|
598
|
-
'export default (m && m.default);',
|
|
599
|
-
...names.map(name => `export const ${name} = m.${name};`),
|
|
600
|
-
]
|
|
601
|
-
return { contents: lines.join('\n'), loader: 'js' }
|
|
602
|
-
})
|
|
603
|
-
},
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
581
|
/**
|
|
608
582
|
* Bundle every route's client entry in a single esbuild build. With `splitting`
|
|
609
583
|
* enabled, esbuild emits the shared dependency graph (preact runtime, ui kit,
|
|
@@ -1404,7 +1378,7 @@ function coreStaticAssetModule(
|
|
|
1404
1378
|
* App-convention .js/.mjs may contain JSX (jsxImportSource is preact), so those parse with the jsx
|
|
1405
1379
|
* loader; scoped to the source roots, so third-party node_modules .js keeps esbuild's default loader.
|
|
1406
1380
|
*/
|
|
1407
|
-
function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
|
|
1381
|
+
function createClientSourcePipeline(config: ResolvedConfig, dev = false, routeId?: string) {
|
|
1408
1382
|
const sourceRewrite = nextCompatEnabled(config)
|
|
1409
1383
|
const compiler = getCompatModeExtensions().reactCompilerOptions(config)
|
|
1410
1384
|
const asyncPre = hasClientSourceAsyncPreTransforms()
|
|
@@ -1466,9 +1440,10 @@ function createClientSourcePipeline(config: ResolvedConfig, dev = false) {
|
|
|
1466
1440
|
specifier => resolveImport(rootFromFile(resolved), resolved, specifier),
|
|
1467
1441
|
target => {
|
|
1468
1442
|
const id = clientReferenceId(target.file, target.exportName)
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1443
|
+
const ref = { ...target, routeId }
|
|
1444
|
+
registerDeferredDynamicRef(id, ref)
|
|
1445
|
+
deferredRefs.set(id, ref)
|
|
1446
|
+
return deferredDynamicChunkHref({ id }, routeId)
|
|
1472
1447
|
},
|
|
1473
1448
|
)
|
|
1474
1449
|
}
|
package/src/client/entry.ts
CHANGED
|
@@ -56,7 +56,8 @@ interface ClientEntryOptions {
|
|
|
56
56
|
shellLayoutOrder?: string[]
|
|
57
57
|
/**
|
|
58
58
|
* Dev split: the served URL a deferred dynamic reference loads from instead
|
|
59
|
-
* of bundling its target into this entry
|
|
59
|
+
* of bundling its target into this entry: the dev split emits that reference as
|
|
60
|
+
* its own entry point of the same build (see buildClientEntry).
|
|
60
61
|
*/
|
|
61
62
|
deferredDynamicHref?: (reference: ClientReference) => string | undefined
|
|
62
63
|
}
|
|
@@ -293,7 +294,7 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
|
|
|
293
294
|
function propsParserSource(nextCompat?: boolean) {
|
|
294
295
|
const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
|
|
295
296
|
return `
|
|
296
|
-
import { ${revive} as __pnextReviveSerializedRefs } from ${JSON.stringify(serializeModulePath())};
|
|
297
|
+
import { ${revive} as __pnextReviveSerializedRefs, hasPromiseProps as __pnextHasPromiseProps, revivePromiseMarkers as __pnextRevivePromiseMarkers } from ${JSON.stringify(serializeModulePath())};
|
|
297
298
|
import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots as __pnextReviveIslandSlots } from ${JSON.stringify(staticSlotsModulePath())};
|
|
298
299
|
// Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
|
|
299
300
|
// inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
|
|
@@ -317,25 +318,72 @@ function parseIslandProps(raw) {
|
|
|
317
318
|
// rebuilds plain objects, so resolving first would restore identity onto
|
|
318
319
|
// objects the revival then replaces).
|
|
319
320
|
const revived = __pnextReviveSerializedRefs(${nextCompat ? 'actions ? actions.reviveProps(props) : props' : 'props'});
|
|
320
|
-
// Promise props
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
321
|
+
// Promise props travel as markers, at the top level (a client slot page's params/searchParams) and
|
|
322
|
+
// nested in plain containers (a dehydrated react-query state's pending-query promise). Revive both
|
|
323
|
+
// into pre-fulfilled promises - see revivePromiseMarkers. The deep walk is gated on a substring
|
|
324
|
+
// test of the raw attribute, so an island with no promise props pays one indexOf.
|
|
325
|
+
return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived) : revived;
|
|
326
|
+
}
|
|
327
|
+
`
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Island hosts pnext emits as `display:contents` custom elements (renderer + compat dynamic). */
|
|
331
|
+
const MEASURED_HOST_TAGS = ['pnext-client', 'pnext-dynamic', 'pnext-static-children']
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A `display:contents` host has no CSS box, so an island child measuring its
|
|
335
|
+
* parentElement (getBoundingClientRect/offsetWidth, or a ResizeObserver on it)
|
|
336
|
+
* sees 0x0 and never resizes — under Next that parent is the app's real
|
|
337
|
+
* container. Upgrading the hosts to custom elements lets their measurement
|
|
338
|
+
* surface delegate to the nearest ancestor that does have a box.
|
|
339
|
+
*/
|
|
340
|
+
export function hostMeasureSource() {
|
|
341
|
+
return `
|
|
342
|
+
function pnextInstallHostMeasure() {
|
|
343
|
+
if (typeof customElements === 'undefined' || window.__PNEXT_HOST_MEASURE__) return;
|
|
344
|
+
window.__PNEXT_HOST_MEASURE__ = true;
|
|
345
|
+
const box = el =>
|
|
346
|
+
el.style.display === 'contents' || getComputedStyle(el).display === 'contents'
|
|
347
|
+
? el.parentElement
|
|
348
|
+
: null;
|
|
349
|
+
class PnextHost extends HTMLElement {
|
|
350
|
+
getBoundingClientRect() {
|
|
351
|
+
const target = box(this);
|
|
352
|
+
return target ? target.getBoundingClientRect() : super.getBoundingClientRect();
|
|
353
|
+
}
|
|
354
|
+
getClientRects() {
|
|
355
|
+
const target = box(this);
|
|
356
|
+
return target ? target.getClientRects() : super.getClientRects();
|
|
329
357
|
}
|
|
330
358
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
359
|
+
for (const key of ['clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft']) {
|
|
360
|
+
Object.defineProperty(PnextHost.prototype, key, {
|
|
361
|
+
configurable: true,
|
|
362
|
+
get() {
|
|
363
|
+
const target = box(this);
|
|
364
|
+
return target ? target[key] : 0;
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
for (const tag of ${JSON.stringify(MEASURED_HOST_TAGS)}) {
|
|
369
|
+
if (!customElements.get(tag)) customElements.define(tag, class extends PnextHost {});
|
|
370
|
+
}
|
|
371
|
+
// Observing the same element twice is a single native observation, so
|
|
372
|
+
// redirecting host -> parent dedupes on its own.
|
|
373
|
+
const observed = target => (target instanceof PnextHost ? (box(target) ?? target) : target);
|
|
374
|
+
const NativeResizeObserver = window.ResizeObserver;
|
|
375
|
+
if (NativeResizeObserver) {
|
|
376
|
+
window.ResizeObserver = class extends NativeResizeObserver {
|
|
377
|
+
observe(target, options) {
|
|
378
|
+
super.observe(observed(target), options);
|
|
379
|
+
}
|
|
380
|
+
unobserve(target) {
|
|
381
|
+
super.unobserve(observed(target));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
338
385
|
}
|
|
386
|
+
pnextInstallHostMeasure();
|
|
339
387
|
`
|
|
340
388
|
}
|
|
341
389
|
|
|
@@ -612,41 +660,6 @@ export interface ClientRuntimeFacts {
|
|
|
612
660
|
/** Module specifier the generated route stubs import their runtime from. */
|
|
613
661
|
export const CLIENT_RUNTIME_MODULE = 'pnext-client-runtime'
|
|
614
662
|
|
|
615
|
-
/** Dev split: single-instance vendor modules the entry shares with on-demand chunks. */
|
|
616
|
-
export const DYN_SHARED_GLOBAL = '__PNEXT_DYN_SHARED__'
|
|
617
|
-
export function dynSharedSpecifiers(nextCompat?: boolean) {
|
|
618
|
-
return nextCompat
|
|
619
|
-
? [
|
|
620
|
-
'preact',
|
|
621
|
-
'preact/hooks',
|
|
622
|
-
'preact/compat',
|
|
623
|
-
'preact/compat/client',
|
|
624
|
-
'preact/jsx-runtime',
|
|
625
|
-
'preact/jsx-dev-runtime',
|
|
626
|
-
]
|
|
627
|
-
: ['preact', 'preact/hooks', 'preact/jsx-runtime']
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// A separately-built dynamic chunk must render with THIS entry's preact (hooks
|
|
631
|
-
// dispatch through the renderer's own `options`), so the entry publishes its
|
|
632
|
-
// vendor namespaces for the chunk build's shared-vendor shims to read. Emitted
|
|
633
|
-
// whenever the dev split is armed: dynamic() calls inside 'use client' modules
|
|
634
|
-
// are rewritten mid-build (pipeline), after this source is generated.
|
|
635
|
-
function dynSharedTableSource(
|
|
636
|
-
nextCompat?: boolean,
|
|
637
|
-
deferredDynamicHref?: (reference: ClientReference) => string | undefined,
|
|
638
|
-
) {
|
|
639
|
-
if (!deferredDynamicHref) return ''
|
|
640
|
-
const specifiers = dynSharedSpecifiers(nextCompat)
|
|
641
|
-
const imports = specifiers.map(
|
|
642
|
-
(specifier, index) => `import * as __pnextDynShared${index} from ${JSON.stringify(specifier)};`,
|
|
643
|
-
)
|
|
644
|
-
const entries = specifiers.map(
|
|
645
|
-
(specifier, index) => `${JSON.stringify(specifier)}: __pnextDynShared${index}`,
|
|
646
|
-
)
|
|
647
|
-
return `${imports.join('\n')}\nwindow.${DYN_SHARED_GLOBAL} = { ${entries.join(', ')} };\n`
|
|
648
|
-
}
|
|
649
|
-
|
|
650
663
|
export function clientRuntimeFacts(
|
|
651
664
|
routes: { route: RouteFacts; shell?: boolean }[],
|
|
652
665
|
nextCompat?: boolean,
|
|
@@ -689,6 +702,7 @@ ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
|
|
|
689
702
|
${revival ? propsParserSource(nextCompat) : ''}
|
|
690
703
|
${revival ? islandHelpersSource(facts) : ''}
|
|
691
704
|
${islandCssHelperSource()}
|
|
705
|
+
${revival ? hostMeasureSource() : ''}
|
|
692
706
|
${mountLifecycleSource(nextCompat)}
|
|
693
707
|
|
|
694
708
|
export function bootstrapRoute(config) {
|
|
@@ -789,7 +803,6 @@ export function clientEntrySource(options: ClientEntryOptions) {
|
|
|
789
803
|
import { bootstrapRoute${needsCss ? ', loadIslandCss' : ''} } from ${JSON.stringify(CLIENT_RUNTIME_MODULE)};
|
|
790
804
|
${pageFile ? `import Page from ${JSON.stringify(pageFile)};` : ''}
|
|
791
805
|
${imports}
|
|
792
|
-
${dynSharedTableSource(nextCompat, options.deferredDynamicHref)}
|
|
793
806
|
${routerImportSource(facts)}
|
|
794
807
|
${boundaryImportSource(nextCompat, hasErrorBoundary(facts), !notFoundFile)}
|
|
795
808
|
|
|
@@ -891,11 +904,11 @@ function visibleDynamicIslandEntrySource(
|
|
|
891
904
|
|
|
892
905
|
return `
|
|
893
906
|
${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
|
|
894
|
-
${dynSharedTableSource(nextCompat, deferredDynamicHref)}
|
|
895
907
|
${islandCssHelperSource()}
|
|
896
908
|
${routerImportSource(facts)}
|
|
897
909
|
${boundaryImportSource(nextCompat, boundary, boundary && !notFoundFile)}
|
|
898
910
|
${layoutSegmentHelperSource(nextCompat)}
|
|
911
|
+
${hostMeasureSource()}
|
|
899
912
|
${mountLifecycleSource(nextCompat)}
|
|
900
913
|
const islands = [
|
|
901
914
|
${islandManifest}
|
|
@@ -961,7 +974,9 @@ function observeVisibleIsland(root, island) {
|
|
|
961
974
|
}
|
|
962
975
|
|
|
963
976
|
function visibleTarget(root) {
|
|
964
|
-
|
|
977
|
+
// The host's OWN box: hostMeasureSource delegates the patched one to the
|
|
978
|
+
// parent, and IntersectionObserver never fires for a display:contents target.
|
|
979
|
+
const rect = Element.prototype.getBoundingClientRect.call(root);
|
|
965
980
|
if (rect.width || rect.height) return root;
|
|
966
981
|
return root.parentElement ?? root;
|
|
967
982
|
}
|
|
@@ -1491,7 +1506,9 @@ ${
|
|
|
1491
1506
|
visibleIslands
|
|
1492
1507
|
? `
|
|
1493
1508
|
function visibleTarget(root) {
|
|
1494
|
-
|
|
1509
|
+
// The host's OWN box: hostMeasureSource delegates the patched one to the
|
|
1510
|
+
// parent, and IntersectionObserver never fires for a display:contents target.
|
|
1511
|
+
const rect = Element.prototype.getBoundingClientRect.call(root);
|
|
1495
1512
|
if (rect.width || rect.height) return root;
|
|
1496
1513
|
return root.parentElement ?? root;
|
|
1497
1514
|
}
|
|
@@ -746,13 +746,10 @@ async function emitFontBytes(
|
|
|
746
746
|
// Next's loader names emitted files `[hash]-s.p.[ext]`: `-s` when a
|
|
747
747
|
// size-adjust fallback font is used, `.p` when the font is preloaded.
|
|
748
748
|
const filename = `${hash}${emit.adjustFontFallback ? '-s' : ''}${emit.preload ? '.p' : ''}.${ext}`
|
|
749
|
-
// Served from `/_next/static/media/`
|
|
750
|
-
//
|
|
749
|
+
// Served from `/_next/static/media/` — files under the out dir's public/ map 1:1 to the URL path,
|
|
750
|
+
// which is the only place dev and build both look them up.
|
|
751
751
|
const mediaSegments = ['_next', 'static', 'media']
|
|
752
|
-
const
|
|
753
|
-
? path.join(context.config.outPath, 'cache')
|
|
754
|
-
: path.join(context.config.outPath, 'public')
|
|
755
|
-
const outDir = path.join(outRoot, ...mediaSegments)
|
|
752
|
+
const outDir = path.join(context.config.outPath, 'public', ...mediaSegments)
|
|
756
753
|
const file = path.join(outDir, filename)
|
|
757
754
|
await mkdir(outDir, { recursive: true })
|
|
758
755
|
if (!existsSync(file)) {
|
package/src/dev/server.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
2
|
import { readFile, readdir, rm, stat, watch } from 'node:fs/promises'
|
|
4
3
|
import path from 'node:path'
|
|
5
4
|
import {
|
|
6
5
|
DEFERRED_DYNAMIC_SIDECAR,
|
|
7
|
-
buildClientDynamicChunk,
|
|
8
6
|
buildClientEntry,
|
|
9
7
|
deferredDynamicRefById,
|
|
10
8
|
prebuiltRuntimeDir,
|
|
@@ -478,14 +476,14 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
478
476
|
|
|
479
477
|
const dynChunkMatch = /^\/__pnext\/client-dyn\/([A-Za-z0-9-]+)\.js$/.exec(url.pathname)
|
|
480
478
|
if (dynChunkMatch?.[1]) {
|
|
481
|
-
const
|
|
482
|
-
|
|
479
|
+
const id = dynChunkMatch[1]
|
|
480
|
+
const file = await profileDevStep(profile, `client dyn chunk ${id}`, () =>
|
|
481
|
+
devDynamicEntryFile(config, routes, id, url.searchParams.get('r')),
|
|
482
|
+
)
|
|
483
|
+
if (!file)
|
|
483
484
|
return finish(
|
|
484
485
|
applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse),
|
|
485
486
|
)
|
|
486
|
-
const file = await profileDevStep(profile, `client dyn chunk ${dynChunkMatch[1]}`, () =>
|
|
487
|
-
buildDevDynamicChunk(config, found.route, found.reference, devImportVersion),
|
|
488
|
-
)
|
|
489
487
|
return finish(
|
|
490
488
|
applyProxyResponse(
|
|
491
489
|
devResponse(await readFile(file), 'text/javascript; charset=utf-8'),
|
|
@@ -493,15 +491,6 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
493
491
|
),
|
|
494
492
|
)
|
|
495
493
|
}
|
|
496
|
-
const dynSharedChunk = /^\/__pnext\/client-dyn\/chunks\/(.+\.js)$/.exec(url.pathname)
|
|
497
|
-
if (dynSharedChunk?.[1]) {
|
|
498
|
-
const chunkFile = dynChunkFiles.get(dynSharedChunk[1])
|
|
499
|
-
if (chunkFile && existsSync(chunkFile))
|
|
500
|
-
return finish(
|
|
501
|
-
applyProxyResponse(devChunkResponse(await readFile(chunkFile)), proxyResponse),
|
|
502
|
-
)
|
|
503
|
-
return finish(applyProxyResponse(new Response('not found', { status: 404 }), proxyResponse))
|
|
504
|
-
}
|
|
505
494
|
|
|
506
495
|
const clientMatch = /^\/__pnext\/client\/(.+)\.js$/.exec(url.pathname)
|
|
507
496
|
if (clientMatch?.[1]) {
|
|
@@ -1081,48 +1070,32 @@ function findDeferredDynamicReference(routes: RouteManifestEntry[], id: string)
|
|
|
1081
1070
|
if (reference) return { route, reference }
|
|
1082
1071
|
}
|
|
1083
1072
|
const registered = deferredDynamicRefById(id)
|
|
1084
|
-
if (registered)
|
|
1073
|
+
if (registered)
|
|
1074
|
+
return {
|
|
1075
|
+
route: routes.find(item => item.id === registered.routeId),
|
|
1076
|
+
reference: { id, ...registered },
|
|
1077
|
+
}
|
|
1085
1078
|
return undefined
|
|
1086
1079
|
}
|
|
1087
1080
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1081
|
+
/**
|
|
1082
|
+
* The on-demand output of a deferred reference: an entry point of its route's own
|
|
1083
|
+
* client build, so it is emitted (and chunk-split against that entry) by the build
|
|
1084
|
+
* the route's bundle already runs. `r` names that route; a reference the scan or
|
|
1085
|
+
* the registry can place needs no query.
|
|
1086
|
+
*/
|
|
1087
|
+
async function devDynamicEntryFile(
|
|
1094
1088
|
config: ResolvedConfig,
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1089
|
+
routes: RouteManifestEntry[],
|
|
1090
|
+
id: string,
|
|
1091
|
+
routeId: string | null,
|
|
1098
1092
|
) {
|
|
1099
|
-
const
|
|
1100
|
-
const
|
|
1101
|
-
if (
|
|
1102
|
-
const outDir = path.
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
'client-dyn',
|
|
1106
|
-
`${reference.id}-${createHash('sha1').update(devImportVersion).digest('hex').slice(0, 8)}`,
|
|
1107
|
-
)
|
|
1108
|
-
const build = (async () => {
|
|
1109
|
-
const outfile = path.join(outDir, `${reference.id}.js`)
|
|
1110
|
-
if (!existsSync(outfile)) {
|
|
1111
|
-
await buildClientDynamicChunk({ config, route, reference, outDir })
|
|
1112
|
-
}
|
|
1113
|
-
const chunksDir = path.join(outDir, 'chunks')
|
|
1114
|
-
if (existsSync(chunksDir)) {
|
|
1115
|
-
for (const entry of await readdir(chunksDir)) {
|
|
1116
|
-
if (entry.endsWith('.js')) dynChunkFiles.set(entry, path.join(chunksDir, entry))
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
return outfile
|
|
1120
|
-
})().catch(error => {
|
|
1121
|
-
dynChunkBuilds.delete(key)
|
|
1122
|
-
throw error
|
|
1123
|
-
})
|
|
1124
|
-
dynChunkBuilds.set(key, build)
|
|
1125
|
-
return build
|
|
1093
|
+
const scoped = routeId ? routes.find(route => route.id === routeId) : undefined
|
|
1094
|
+
const route = scoped ?? findDeferredDynamicReference(routes, id)?.route
|
|
1095
|
+
if (!route) return undefined
|
|
1096
|
+
const outDir = path.dirname(await buildDevClient(config, route))
|
|
1097
|
+
const file = path.join(outDir, `${id}.js`)
|
|
1098
|
+
return isInside(outDir, file) && existsSync(file) ? file : undefined
|
|
1126
1099
|
}
|
|
1127
1100
|
|
|
1128
1101
|
/** A cached entry never re-ran the pipeline rewrite: recover its chunk-id map. */
|
|
@@ -1130,7 +1103,7 @@ async function loadDeferredDynamicSidecar(outDir: string) {
|
|
|
1130
1103
|
try {
|
|
1131
1104
|
const parsed = JSON.parse(
|
|
1132
1105
|
await readFile(path.join(outDir, DEFERRED_DYNAMIC_SIDECAR), 'utf8'),
|
|
1133
|
-
) as Record<string, { file: string; exportName: string }>
|
|
1106
|
+
) as Record<string, { file: string; exportName: string; routeId?: string }>
|
|
1134
1107
|
for (const [id, ref] of Object.entries(parsed)) {
|
|
1135
1108
|
if (ref?.file && ref.exportName) registerDeferredDynamicRef(id, ref)
|
|
1136
1109
|
}
|
package/src/render/renderer.ts
CHANGED
|
@@ -6140,34 +6140,39 @@ async function serializablePromiseProps(props: ServerVNodeProps): Promise<Server
|
|
|
6140
6140
|
next[key] =
|
|
6141
6141
|
key === 'children'
|
|
6142
6142
|
? value
|
|
6143
|
-
: isPromise(value)
|
|
6143
|
+
: isPromise(value) && isHangingPromise(value)
|
|
6144
6144
|
? // A fallback shell's params/searchParams promise HANGS: awaiting it
|
|
6145
6145
|
// here rejects with PostponeError outside every <Suspense>, which
|
|
6146
6146
|
// destroys the whole partial shell (no shell -> no segment
|
|
6147
6147
|
// artifacts). Emit the placeholder instead; withRequestRouteParams
|
|
6148
6148
|
// re-stamps it with the serving request's values.
|
|
6149
|
-
|
|
6150
|
-
? placeholderPromiseMarker(key)
|
|
6151
|
-
: promiseMarker(await value)
|
|
6149
|
+
placeholderPromiseMarker(key)
|
|
6152
6150
|
: await deepResolveNestedPromises(value)
|
|
6153
6151
|
}
|
|
6154
6152
|
return next
|
|
6155
6153
|
}
|
|
6156
6154
|
|
|
6157
6155
|
/**
|
|
6158
|
-
*
|
|
6159
|
-
*
|
|
6160
|
-
*
|
|
6161
|
-
*
|
|
6156
|
+
* Await every Promise in a prop and replace it with a `promiseMarker`, at the top level (a page's
|
|
6157
|
+
* params) and buried in a nested object/array/Map/Set alike (a react-query dehydrated state keeps
|
|
6158
|
+
* its pending-query promise at `state.queries[n].promise`). The marker is what preserves PROMISE
|
|
6159
|
+
* IDENTITY across the wire: the client revives it into a pre-fulfilled promise, so a consumer that
|
|
6160
|
+
* calls `.then` on it - use(), react-query's tryResolveSync - still finds a thenable.
|
|
6161
|
+
*
|
|
6162
|
+
* A hanging promise (partial prerender) is never awaited - that would wedge the render - and a
|
|
6163
|
+
* nested one has no re-stampable identity, so it settles as null exactly like a non-request-API
|
|
6164
|
+
* hanging prop.
|
|
6162
6165
|
*/
|
|
6163
6166
|
async function deepResolveNestedPromises(
|
|
6164
6167
|
value: unknown,
|
|
6165
6168
|
seen = new Map<object, unknown>(),
|
|
6166
6169
|
): Promise<unknown> {
|
|
6167
|
-
if (isPromise(value))
|
|
6170
|
+
if (isPromise(value)) {
|
|
6171
|
+
if (isHangingPromise(value)) return promiseMarker(null)
|
|
6172
|
+
return promiseMarker(await deepResolveNestedPromises(await value, seen))
|
|
6173
|
+
}
|
|
6168
6174
|
if (value === null || typeof value !== 'object') return value
|
|
6169
|
-
|
|
6170
|
-
if (existing !== undefined) return existing
|
|
6175
|
+
if (seen.has(value)) return seen.get(value)
|
|
6171
6176
|
if (Array.isArray(value)) {
|
|
6172
6177
|
const items: unknown[] = []
|
|
6173
6178
|
seen.set(value, items)
|
|
@@ -6432,18 +6437,58 @@ export async function renderActionReturnElement(
|
|
|
6432
6437
|
return renderVNodeToString(h(Fragment, null, resolved))
|
|
6433
6438
|
}
|
|
6434
6439
|
|
|
6440
|
+
/**
|
|
6441
|
+
* preact's stream renderer builds the SHELL synchronously: a component that suspends with no
|
|
6442
|
+
* <Suspense> registered above it escapes `start()` as this error instead of being awaited. The
|
|
6443
|
+
* non-streaming path has no such limit (renderToStringAsync retries the suspension), so falling
|
|
6444
|
+
* back to it keeps a suspending island - a react-query island reading a dehydrated promise, say -
|
|
6445
|
+
* rendering exactly as it does on a non-streamed request.
|
|
6446
|
+
*/
|
|
6447
|
+
const SYNC_SUSPENSE_MESSAGE = 'Use "renderToStringAsync" for suspenseful rendering.'
|
|
6448
|
+
|
|
6449
|
+
function isSyncSuspenseError(error: unknown): boolean {
|
|
6450
|
+
return error instanceof Error && error.message === SYNC_SUSPENSE_MESSAGE
|
|
6451
|
+
}
|
|
6452
|
+
|
|
6453
|
+
/** Sentinel: the shell suspended synchronously and must be re-rendered by the awaited path. */
|
|
6454
|
+
const SHELL_SUSPENDED = Symbol('pnext.shellSuspended')
|
|
6455
|
+
|
|
6435
6456
|
async function renderClientPageStreamShell(vnode: VNode, state: StreamState): Promise<string> {
|
|
6436
|
-
|
|
6457
|
+
const shell = await suspendingStreamScope.run(true, () =>
|
|
6458
|
+
renderSuspendingStreamShell(vnode, state),
|
|
6459
|
+
)
|
|
6460
|
+
if (shell !== SHELL_SUSPENDED) return shell
|
|
6461
|
+
// Deliberately OUTSIDE the suspending-stream scope: that scope disables preact's error
|
|
6462
|
+
// boundaries, and the awaited path needs them back on to match the non-streamed render exactly.
|
|
6463
|
+
return renderVNodeToString(vnode, state)
|
|
6437
6464
|
}
|
|
6438
6465
|
|
|
6439
|
-
async function renderSuspendingStreamShell(
|
|
6466
|
+
async function renderSuspendingStreamShell(
|
|
6467
|
+
vnode: VNode,
|
|
6468
|
+
state: StreamState,
|
|
6469
|
+
): Promise<string | typeof SHELL_SUSPENDED> {
|
|
6440
6470
|
state.clientPageStream = true
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
let
|
|
6471
|
+
// The shell renders inside `new ReadableStream`'s start(), so a synchronous suspension throws
|
|
6472
|
+
// out of the CONSTRUCTOR - the read below never happens. Both have to be guarded.
|
|
6473
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
6474
|
+
let first: Awaited<ReturnType<NonNullable<typeof reader>['read']>>
|
|
6444
6475
|
try {
|
|
6476
|
+
const stream = actionSerializeScope.run(state, () => renderToReadableStream(vnode))
|
|
6477
|
+
// preact hangs a SECOND promise off the stream - `allReady`, resolved when the whole render
|
|
6478
|
+
// (shell + every suspended boundary) lands - and rejects BOTH it and the stream with the same
|
|
6479
|
+
// error. Reading the stream owns one of them; nothing owns `allReady`, so a shell that fails
|
|
6480
|
+
// reports an unhandled rejection on top of the failure the caller already handled. pnext never
|
|
6481
|
+
// awaits full-render completion (the deferred tail below drains the reader instead), so the
|
|
6482
|
+
// handler is a discard rather than a second error path.
|
|
6483
|
+
void (stream as { allReady?: Promise<void> }).allReady?.catch(() => undefined)
|
|
6484
|
+
reader = stream.getReader()
|
|
6445
6485
|
first = await reader.read()
|
|
6446
6486
|
} catch (error) {
|
|
6487
|
+
if (isSyncSuspenseError(error)) {
|
|
6488
|
+
state.clientPageStream = false
|
|
6489
|
+
void reader?.cancel().catch(() => undefined)
|
|
6490
|
+
return SHELL_SUSPENDED
|
|
6491
|
+
}
|
|
6447
6492
|
// A whole-page client route streams WITHOUT the ClientPageSsrBoundary (an error boundary
|
|
6448
6493
|
// disables preact's stream suspense), so nothing tags a throw escaping this render. It came
|
|
6449
6494
|
// from the client tree by construction, so tag it here - otherwise global-error would redact
|
package/src/render/slots.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type SegmentMatch,
|
|
17
17
|
} from '../routing/slots'
|
|
18
18
|
import { toPosixPath } from '../utils/fs'
|
|
19
|
+
import { PROMISE_MARKER_KEY, revivePromiseMarkers } from '../utils/serialize'
|
|
19
20
|
import type { PageProps, RouteManifestEntry, RouteParamValue, ServerComponent } from '../types'
|
|
20
21
|
|
|
21
22
|
interface SlotRenderOptions {
|
|
@@ -394,8 +395,6 @@ async function renderSlotComponent<Options extends SlotRenderOptions>(
|
|
|
394
395
|
return h(Component as ComponentType<PageProps>, props)
|
|
395
396
|
}
|
|
396
397
|
|
|
397
|
-
const PROMISE_MARKER_KEY = '__pnextPromise'
|
|
398
|
-
|
|
399
398
|
export function promiseMarker(value: unknown) {
|
|
400
399
|
return { [PROMISE_MARKER_KEY]: value ?? null }
|
|
401
400
|
}
|
|
@@ -418,17 +417,12 @@ export function promisePlaceholderJson(kind: 'params' | 'searchParams'): string
|
|
|
418
417
|
return `{"${PROMISE_MARKER_KEY}":null,"${PROMISE_PLACEHOLDER_KEY}":"${kind}"}`
|
|
419
418
|
}
|
|
420
419
|
|
|
421
|
-
/**
|
|
420
|
+
/**
|
|
421
|
+
* Revive promise markers into thenables for the SSR pass of an island - the same walk the client
|
|
422
|
+
* entry runs, so a nested marker is a promise on both sides rather than a bare object on one.
|
|
423
|
+
*/
|
|
422
424
|
export function revivePromiseProps(props: Record<string, unknown>) {
|
|
423
|
-
|
|
424
|
-
for (const [key, value] of Object.entries(props)) {
|
|
425
|
-
if (value && typeof value === 'object' && PROMISE_MARKER_KEY in value) {
|
|
426
|
-
out[key] = Promise.resolve((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
|
|
427
|
-
} else {
|
|
428
|
-
out[key] = value
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return out
|
|
425
|
+
return revivePromiseMarkers({ ...props })
|
|
432
426
|
}
|
|
433
427
|
|
|
434
428
|
function slotPageProps<Options extends SlotRenderOptions>(
|
package/src/utils/serialize.ts
CHANGED
|
@@ -19,6 +19,12 @@ const SET_MARKER = '$$pnext_set'
|
|
|
19
19
|
// restoring identity. Only true cycles are encoded this way - a value that merely appears twice in different
|
|
20
20
|
// branches is still expanded, so every acyclic payload is byte-identical to what it was before.
|
|
21
21
|
const REF_MARKER = '$$pnext_ref'
|
|
22
|
+
// Promise-valued island props ride as `{__pnextPromise: resolved}` so the value keeps its PROMISE
|
|
23
|
+
// IDENTITY across the wire - top level (page params) and nested in plain containers (a react-query
|
|
24
|
+
// dehydrated state carries its pending-query promise inside `state.queries[n].promise`). The render
|
|
25
|
+
// half writes the marker (render/slots promiseMarker); both revive halves live here so the
|
|
26
|
+
// preact-free client entry can import them.
|
|
27
|
+
export const PROMISE_MARKER_KEY = '__pnextPromise'
|
|
22
28
|
|
|
23
29
|
type TypedArray =
|
|
24
30
|
| Int8Array
|
|
@@ -197,6 +203,49 @@ function refTarget(root: unknown, node: Record<string, unknown>): unknown {
|
|
|
197
203
|
return current
|
|
198
204
|
}
|
|
199
205
|
|
|
206
|
+
/** Cheap gate: an island whose raw props carry no marker skips the revive walk entirely. */
|
|
207
|
+
export function hasPromiseProps(raw: string) {
|
|
208
|
+
return raw.includes(PROMISE_MARKER_KEY)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Revive every `promiseMarker` in `props` - top level and nested inside plain objects/arrays - into a
|
|
213
|
+
* PRE-FULFILLED promise (React `use()` protocol: status/value readable synchronously). A bare
|
|
214
|
+
* `Promise.resolve` would make `use()` suspend during hydration, and a suspended hydration re-render
|
|
215
|
+
* appends fragment siblings instead of reusing the server DOM.
|
|
216
|
+
*
|
|
217
|
+
* Nested containers are rewritten in place (the client just parsed them; the server already
|
|
218
|
+
* serialized the wire bytes before this runs), the root is not.
|
|
219
|
+
*/
|
|
220
|
+
export function revivePromiseMarkers<T>(props: T): T {
|
|
221
|
+
return reviveMarkers(props, new Set()) as T
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function reviveMarkers(value: unknown, seen: Set<object>): unknown {
|
|
225
|
+
if (value === null || typeof value !== 'object' || seen.has(value)) return value
|
|
226
|
+
if (PROMISE_MARKER_KEY in value) {
|
|
227
|
+
return fulfilledPromise((value as Record<string, unknown>)[PROMISE_MARKER_KEY])
|
|
228
|
+
}
|
|
229
|
+
seen.add(value)
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
for (let index = 0; index < value.length; index++) {
|
|
232
|
+
value[index] = reviveMarkers(value[index], seen)
|
|
233
|
+
}
|
|
234
|
+
return value
|
|
235
|
+
}
|
|
236
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return value
|
|
237
|
+
const target = value as Record<string, unknown>
|
|
238
|
+
for (const key of Object.keys(target)) target[key] = reviveMarkers(target[key], seen)
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fulfilledPromise(value: unknown) {
|
|
243
|
+
const promise = Promise.resolve(value) as Promise<unknown> & { status: string; value: unknown }
|
|
244
|
+
promise.status = 'fulfilled'
|
|
245
|
+
promise.value = value
|
|
246
|
+
return promise
|
|
247
|
+
}
|
|
248
|
+
|
|
200
249
|
/** A preact vnode: `h` sets `constructor` to undefined on every one it makes. */
|
|
201
250
|
export function isElementLike(value: unknown): boolean {
|
|
202
251
|
if (value === null || typeof value !== 'object') return false
|
|
@@ -227,6 +276,14 @@ export function assertSerializable(
|
|
|
227
276
|
if (value instanceof Date || value instanceof URL) {
|
|
228
277
|
throw new Error(`${path} must be a plain JSON value`)
|
|
229
278
|
}
|
|
279
|
+
// A promise the render half could not await: island props are awaited at the top level and inside
|
|
280
|
+
// plain objects/arrays/Maps/Sets, so anything left here sits behind a class instance (or is a
|
|
281
|
+
// server-action argument, where promises are never allowed). Say so instead of "plain object".
|
|
282
|
+
if (typeof (value as { then?: unknown }).then === 'function') {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`${path} is a Promise: a promise prop must be reachable at the top level of the props or nested in plain objects/arrays/Maps/Sets - one held by a class instance cannot be awaited or serialized, and server actions take no promises at all`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
230
287
|
if (typeof value !== 'object') return
|
|
231
288
|
// A value that contains ITSELF is serializable: encodeBinary writes it as a
|
|
232
289
|
// `$$pnext_ref` back-reference (React flight does the same), so stop walking
|
|
@@ -241,8 +298,8 @@ export function assertSerializable(
|
|
|
241
298
|
return
|
|
242
299
|
}
|
|
243
300
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
301
|
+
// Elements stay illegal inside Map/Set: the slot walk (render/static-slots) does not enter
|
|
302
|
+
// them, so allowing one would silently ship a JSON-mangled vnode instead of a `$$pnext_slot`.
|
|
246
303
|
if (value instanceof Map) {
|
|
247
304
|
let index = 0
|
|
248
305
|
for (const [key, item] of value.entries()) {
|