@wular/pnext 0.0.7 → 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/analyze.ts +103 -48
- package/src/cli/dev.ts +5 -1
- package/src/client/build.ts +94 -119
- package/src/client/entry.ts +87 -66
- 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/render/static-slots-revive.ts +65 -0
- package/src/render/static-slots.ts +3 -54
- package/src/utils/serialize.ts +59 -2
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
|
}
|
|
@@ -91,7 +92,9 @@ function islandContextModulePath() {
|
|
|
91
92
|
// Wire-marker revival shared with the server encoder (utils/serialize.ts):
|
|
92
93
|
// island props carrying a CYCLE travel as `$$pnext_ref` back-references.
|
|
93
94
|
function staticSlotsModulePath() {
|
|
94
|
-
|
|
95
|
+
// The preact-free half: entries import it eagerly, so it must not drag preact into a
|
|
96
|
+
// visible-dynamic entry's static graph.
|
|
97
|
+
return path.join(import.meta.dirname, '../render/static-slots-revive.ts')
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
function serializeModulePath() {
|
|
@@ -291,15 +294,17 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
|
|
|
291
294
|
function propsParserSource(nextCompat?: boolean) {
|
|
292
295
|
const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
|
|
293
296
|
return `
|
|
294
|
-
import { ${revive} as __pnextReviveSerializedRefs } from ${JSON.stringify(serializeModulePath())};
|
|
297
|
+
import { ${revive} as __pnextReviveSerializedRefs, hasPromiseProps as __pnextHasPromiseProps, revivePromiseMarkers as __pnextRevivePromiseMarkers } from ${JSON.stringify(serializeModulePath())};
|
|
295
298
|
import { hasIslandStaticSlots as __pnextHasIslandSlots, reviveIslandStaticSlots as __pnextReviveIslandSlots } from ${JSON.stringify(staticSlotsModulePath())};
|
|
296
299
|
// Element-valued props: the wire carries a \`$$pnext_slot\` id per element and the server rendered it
|
|
297
300
|
// inside a matching \`pnext-static-slot\` host, adopted here exactly like element children. Islands
|
|
298
301
|
// with no element props skip the walk on a substring test of the raw attribute.
|
|
299
|
-
|
|
302
|
+
// \`h\` is threaded in rather than imported by the slot reviver: that would put preact in the static
|
|
303
|
+
// graph of entries (visible-dynamic) that otherwise only lazy-import it.
|
|
304
|
+
function islandProps(raw, root, toChildren, h) {
|
|
300
305
|
const props = parseIslandProps(raw);
|
|
301
306
|
if (!__pnextHasIslandSlots(raw)) return props;
|
|
302
|
-
return __pnextReviveIslandSlots(props, root, toChildren);
|
|
307
|
+
return __pnextReviveIslandSlots(props, root, toChildren, h);
|
|
303
308
|
}
|
|
304
309
|
function parseIslandProps(raw) {
|
|
305
310
|
const props = JSON.parse(raw || '{}');${
|
|
@@ -313,25 +318,72 @@ function parseIslandProps(raw) {
|
|
|
313
318
|
// rebuilds plain objects, so resolving first would restore identity onto
|
|
314
319
|
// objects the revival then replaces).
|
|
315
320
|
const revived = __pnextReviveSerializedRefs(${nextCompat ? 'actions ? actions.reviveProps(props) : props' : 'props'});
|
|
316
|
-
// Promise props
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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();
|
|
325
357
|
}
|
|
326
358
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
}
|
|
334
385
|
}
|
|
386
|
+
pnextInstallHostMeasure();
|
|
335
387
|
`
|
|
336
388
|
}
|
|
337
389
|
|
|
@@ -608,41 +660,6 @@ export interface ClientRuntimeFacts {
|
|
|
608
660
|
/** Module specifier the generated route stubs import their runtime from. */
|
|
609
661
|
export const CLIENT_RUNTIME_MODULE = 'pnext-client-runtime'
|
|
610
662
|
|
|
611
|
-
/** Dev split: single-instance vendor modules the entry shares with on-demand chunks. */
|
|
612
|
-
export const DYN_SHARED_GLOBAL = '__PNEXT_DYN_SHARED__'
|
|
613
|
-
export function dynSharedSpecifiers(nextCompat?: boolean) {
|
|
614
|
-
return nextCompat
|
|
615
|
-
? [
|
|
616
|
-
'preact',
|
|
617
|
-
'preact/hooks',
|
|
618
|
-
'preact/compat',
|
|
619
|
-
'preact/compat/client',
|
|
620
|
-
'preact/jsx-runtime',
|
|
621
|
-
'preact/jsx-dev-runtime',
|
|
622
|
-
]
|
|
623
|
-
: ['preact', 'preact/hooks', 'preact/jsx-runtime']
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// A separately-built dynamic chunk must render with THIS entry's preact (hooks
|
|
627
|
-
// dispatch through the renderer's own `options`), so the entry publishes its
|
|
628
|
-
// vendor namespaces for the chunk build's shared-vendor shims to read. Emitted
|
|
629
|
-
// whenever the dev split is armed: dynamic() calls inside 'use client' modules
|
|
630
|
-
// are rewritten mid-build (pipeline), after this source is generated.
|
|
631
|
-
function dynSharedTableSource(
|
|
632
|
-
nextCompat?: boolean,
|
|
633
|
-
deferredDynamicHref?: (reference: ClientReference) => string | undefined,
|
|
634
|
-
) {
|
|
635
|
-
if (!deferredDynamicHref) return ''
|
|
636
|
-
const specifiers = dynSharedSpecifiers(nextCompat)
|
|
637
|
-
const imports = specifiers.map(
|
|
638
|
-
(specifier, index) => `import * as __pnextDynShared${index} from ${JSON.stringify(specifier)};`,
|
|
639
|
-
)
|
|
640
|
-
const entries = specifiers.map(
|
|
641
|
-
(specifier, index) => `${JSON.stringify(specifier)}: __pnextDynShared${index}`,
|
|
642
|
-
)
|
|
643
|
-
return `${imports.join('\n')}\nwindow.${DYN_SHARED_GLOBAL} = { ${entries.join(', ')} };\n`
|
|
644
|
-
}
|
|
645
|
-
|
|
646
663
|
export function clientRuntimeFacts(
|
|
647
664
|
routes: { route: RouteFacts; shell?: boolean }[],
|
|
648
665
|
nextCompat?: boolean,
|
|
@@ -685,6 +702,7 @@ ${revival ? layoutSegmentHelperSource(nextCompat) : ''}
|
|
|
685
702
|
${revival ? propsParserSource(nextCompat) : ''}
|
|
686
703
|
${revival ? islandHelpersSource(facts) : ''}
|
|
687
704
|
${islandCssHelperSource()}
|
|
705
|
+
${revival ? hostMeasureSource() : ''}
|
|
688
706
|
${mountLifecycleSource(nextCompat)}
|
|
689
707
|
|
|
690
708
|
export function bootstrapRoute(config) {
|
|
@@ -785,7 +803,6 @@ export function clientEntrySource(options: ClientEntryOptions) {
|
|
|
785
803
|
import { bootstrapRoute${needsCss ? ', loadIslandCss' : ''} } from ${JSON.stringify(CLIENT_RUNTIME_MODULE)};
|
|
786
804
|
${pageFile ? `import Page from ${JSON.stringify(pageFile)};` : ''}
|
|
787
805
|
${imports}
|
|
788
|
-
${dynSharedTableSource(nextCompat, options.deferredDynamicHref)}
|
|
789
806
|
${routerImportSource(facts)}
|
|
790
807
|
${boundaryImportSource(nextCompat, hasErrorBoundary(facts), !notFoundFile)}
|
|
791
808
|
|
|
@@ -887,11 +904,11 @@ function visibleDynamicIslandEntrySource(
|
|
|
887
904
|
|
|
888
905
|
return `
|
|
889
906
|
${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
|
|
890
|
-
${dynSharedTableSource(nextCompat, deferredDynamicHref)}
|
|
891
907
|
${islandCssHelperSource()}
|
|
892
908
|
${routerImportSource(facts)}
|
|
893
909
|
${boundaryImportSource(nextCompat, boundary, boundary && !notFoundFile)}
|
|
894
910
|
${layoutSegmentHelperSource(nextCompat)}
|
|
911
|
+
${hostMeasureSource()}
|
|
895
912
|
${mountLifecycleSource(nextCompat)}
|
|
896
913
|
const islands = [
|
|
897
914
|
${islandManifest}
|
|
@@ -957,7 +974,9 @@ function observeVisibleIsland(root, island) {
|
|
|
957
974
|
}
|
|
958
975
|
|
|
959
976
|
function visibleTarget(root) {
|
|
960
|
-
|
|
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);
|
|
961
980
|
if (rect.width || rect.height) return root;
|
|
962
981
|
return root.parentElement ?? root;
|
|
963
982
|
}
|
|
@@ -978,7 +997,7 @@ async function mountIslandTree(root, island) {
|
|
|
978
997
|
]);
|
|
979
998
|
${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
980
999
|
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));
|
|
1000
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
|
|
982
1001
|
const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
|
|
983
1002
|
if (source !== root) adoptPreserved(render, root, wrapped);
|
|
984
1003
|
else mount(hydrate, render, root, wrapped);
|
|
@@ -991,7 +1010,7 @@ async function mountIslandTree(root, island) {
|
|
|
991
1010
|
const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), island.load()]);
|
|
992
1011
|
const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
993
1012
|
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));
|
|
1013
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
|
|
995
1014
|
if (source !== root) adoptPreserved(render, root, vnode);
|
|
996
1015
|
else mount(hydrate, render, root, vnode);
|
|
997
1016
|
}`
|
|
@@ -1077,7 +1096,7 @@ async function domNode(h, node) {
|
|
|
1077
1096
|
if (!island) return h(element.localName, domProps(element), await domChildren(h, element));
|
|
1078
1097
|
const Component = island.Component ?? await island.load();
|
|
1079
1098
|
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));
|
|
1099
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, element, node => domChildren(h, node), h), await staticChildren(h, element, island.id));
|
|
1081
1100
|
return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
|
|
1082
1101
|
}
|
|
1083
1102
|
|
|
@@ -1364,7 +1383,7 @@ async function mountIslandTree(root, island) {
|
|
|
1364
1383
|
// Preserved across a soft navigation: re-render in place with the incoming
|
|
1365
1384
|
// document's props/children so component state survives while the routed
|
|
1366
1385
|
// content under the island updates.
|
|
1367
|
-
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
|
|
1386
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
|
|
1368
1387
|
render(${wrapInBoundary('vnode', nextCompat)}, root);
|
|
1369
1388
|
pnextMountedRoots.add(root);
|
|
1370
1389
|
return;
|
|
@@ -1375,7 +1394,7 @@ async function mountIslandTree(root, island) {
|
|
|
1375
1394
|
root.replaceChildren(...incoming.childNodes);
|
|
1376
1395
|
root.__pnextLive = undefined;
|
|
1377
1396
|
}
|
|
1378
|
-
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
|
|
1397
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root', nextCompat)};
|
|
1379
1398
|
mount(root, ${wrapInBoundary('vnode', nextCompat)});
|
|
1380
1399
|
}
|
|
1381
1400
|
|
|
@@ -1448,7 +1467,7 @@ async function domNode(node) {
|
|
|
1448
1467
|
: ''
|
|
1449
1468
|
}
|
|
1450
1469
|
const Component = island.Component ?? await island.load();
|
|
1451
|
-
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren)', 'children', 'element', nextCompat), nextCompat)};
|
|
1470
|
+
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element', nextCompat), nextCompat)};
|
|
1452
1471
|
}
|
|
1453
1472
|
|
|
1454
1473
|
if (Page && element.id === 'pnext-page') {
|
|
@@ -1487,7 +1506,9 @@ ${
|
|
|
1487
1506
|
visibleIslands
|
|
1488
1507
|
? `
|
|
1489
1508
|
function visibleTarget(root) {
|
|
1490
|
-
|
|
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);
|
|
1491
1512
|
if (rect.width || rect.height) return root;
|
|
1492
1513
|
return root.parentElement ?? root;
|
|
1493
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>(
|
|
@@ -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
|
+
}
|