@wular/pnext 0.0.19 → 0.0.21
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/README.md +2 -2
- package/package.json +1 -1
- package/reference/compat.md +2 -2
- package/reference/getting-started.md +2 -2
- package/src/cli/adapters/vercel.ts +27 -0
- package/src/cli/analyze.ts +8 -7
- package/src/cli/boot/trace.ts +9 -3
- package/src/cli/build/cache.ts +324 -0
- package/src/cli/build.ts +42 -18
- package/src/cli/create.ts +4 -4
- package/src/cli/dev.ts +23 -0
- package/src/cli/migrate/package-json.ts +3 -2
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/migrate/run.ts +2 -2
- package/src/cli/migrate/scan.ts +128 -1
- package/src/cli/serve/pipeline.ts +42 -42
- package/src/cli/start.ts +20 -20
- package/src/client/build.ts +64 -35
- package/src/client/entry.ts +228 -16
- package/src/client/router/page-slot.ts +81 -0
- package/src/client/router/runtime.ts +107 -112
- package/src/compat/bundler/cjs-exports.ts +26 -20
- package/src/compat/client/css-order.ts +1 -1
- package/src/compat/client/navigation-scroll.ts +36 -40
- package/src/compat/css/modules.ts +6 -2
- package/src/compat/metadata.ts +0 -9
- package/src/compat/next/font/cache.ts +10 -22
- package/src/compat/next/font/runtime.ts +20 -0
- package/src/compat/next/legacy-request-apis.ts +41 -5
- package/src/compat/next/script.tsx +16 -24
- package/src/compat/react/client-lite.ts +1 -0
- package/src/compat/react/client.ts +11 -0
- package/src/compat/react/dom-client.ts +11 -2
- package/src/compat/react/dom-react-server.ts +4 -0
- package/src/compat/react/dom.ts +11 -2
- package/src/compat/react/internals.ts +65 -0
- package/src/compat/react/parity.ts +47 -0
- package/src/compat/react/preact.ts +31 -1
- package/src/compat/react/react-server.ts +7 -0
- package/src/compat/react/server.ts +16 -2
- package/src/compat/react/style-values.ts +68 -0
- package/src/compat/register/proxy.ts +3 -1
- package/src/compat/register/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- package/src/compat/register/typed-routes.ts +0 -2
- package/src/compat/tsconfig-defaults.ts +4 -0
- package/src/compat/typecheck/check.ts +26 -71
- package/src/compat/typecheck/worker.ts +3 -2
- package/src/compat/validation/validate.ts +2 -0
- package/src/compat-bootstrap.ts +4 -0
- package/src/css/build.ts +153 -14
- package/src/css/postcss.ts +32 -3
- package/src/dev/restart/client-key.ts +1 -1
- package/src/dev/restart/global-css.ts +12 -94
- package/src/dev/restart/record.ts +106 -0
- package/src/dev/restart/route-facts.ts +11 -90
- package/src/dev/server.ts +208 -41
- package/src/extensions.ts +69 -3
- package/src/render/hooks.ts +1 -1
- package/src/render/metadata.ts +89 -17
- package/src/render/renderer.ts +325 -138
- package/src/render/resource-hints.ts +62 -9
- package/src/render/slots.tsx +2 -1
- package/src/resolve/scan-facts.ts +13 -5
- package/src/routing/forwarded.ts +20 -7
- package/src/routing/handler.ts +6 -2
- package/src/routing/href.ts +11 -1
- package/src/routing/metadata-files.ts +16 -3
- package/src/routing/proxy.ts +6 -3
- package/src/routing/routes.ts +253 -34
- package/src/runtime/fetch-host.ts +42 -0
- package/src/runtime/fingerprint.ts +132 -0
- package/src/runtime/loader.ts +7 -5
- package/src/runtime/module-cache.ts +3 -36
- package/src/runtime/modules.ts +333 -77
- package/src/runtime/vendor-build.ts +34 -5
- package/src/runtime/vendor.ts +1 -18
- package/src/types.ts +6 -0
- package/src/utils/asset-hash.ts +22 -0
- package/src/utils/code.ts +41 -0
- package/src/utils/html.ts +4 -1
- package/src/utils/registry.ts +70 -0
- package/src/utils/serialize.ts +66 -7
package/src/client/build.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
3
|
-
import { copyFile, mkdir, readFile, rename, stat } from 'node:fs/promises'
|
|
3
|
+
import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import {
|
|
6
6
|
build,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from 'esbuild'
|
|
16
16
|
import { foldInitialChunks } from './chunk-fold'
|
|
17
17
|
import { clientEntryName } from './chunk-name'
|
|
18
|
+
import { assetContentHash } from '../utils/asset-hash'
|
|
18
19
|
import { clientProfile } from './profile'
|
|
19
20
|
import { frameworkRuntimeAliasEntries, publicEnvDefines, type ResolvedConfig } from '../config'
|
|
20
21
|
import { cssModuleClientPlugin } from '../css/build'
|
|
@@ -57,6 +58,7 @@ import { shouldReactCompile, transformReactCompiler } from './react-compiler'
|
|
|
57
58
|
import { createVerboseLogger, formatDuration } from '../utils/verbose'
|
|
58
59
|
import { dim as dimText } from '../utils/ansi'
|
|
59
60
|
import { traceEnabled } from '../utils/trace-flags'
|
|
61
|
+
import { frameworkFingerprint } from '../runtime/fingerprint'
|
|
60
62
|
import type { RouteManifestEntry } from '../types'
|
|
61
63
|
import {
|
|
62
64
|
applyClientSourceAsyncPreTransforms,
|
|
@@ -477,16 +479,19 @@ export async function buildClientEntry({ config, route, outDir, dev }: ClientBui
|
|
|
477
479
|
const outfile = path.join(outDir, `${entryName}.js`)
|
|
478
480
|
const pipeline = createClientSourcePipeline(config, dev === true, route.id)
|
|
479
481
|
pipeline.warmRoutes([route])
|
|
480
|
-
const runtimeFacts =
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
482
|
+
const runtimeFacts: ClientRuntimeFacts = {
|
|
483
|
+
...clientRuntimeFacts(
|
|
484
|
+
[
|
|
485
|
+
{
|
|
486
|
+
route,
|
|
487
|
+
shell: hasClientRootLayout(config, route),
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
nextCompatEnabled(config),
|
|
491
|
+
suspense,
|
|
492
|
+
),
|
|
493
|
+
dev: dev === true,
|
|
494
|
+
}
|
|
490
495
|
const prebuilt = await clientProfile.timeAsync('prebuilt', () =>
|
|
491
496
|
preparePrebuilt(
|
|
492
497
|
config,
|
|
@@ -654,9 +659,41 @@ export async function buildClientEntries({
|
|
|
654
659
|
}
|
|
655
660
|
}
|
|
656
661
|
})
|
|
662
|
+
await clientProfile.timeAsync('fingerprint', () => fingerprintClientEntries(outDir, entries))
|
|
657
663
|
clientProfile.report('client stage')
|
|
658
664
|
}
|
|
659
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Give every route entry a CONTENT-hashed name, replacing the route-identity hash
|
|
668
|
+
* clientEntryName picks for the build. These are served from /_next/static, which
|
|
669
|
+
* is immutable: a name that outlives its bytes pins a stale runtime in every
|
|
670
|
+
* browser that saw the previous deploy. Two routes whose entries are byte
|
|
671
|
+
* identical collapse onto one file — a URL they can honestly share.
|
|
672
|
+
*/
|
|
673
|
+
async function fingerprintClientEntries(outDir: string, entries: ClientEntry[]) {
|
|
674
|
+
await Promise.all(
|
|
675
|
+
entries.map(async entry => {
|
|
676
|
+
const from = path.join(outDir, `${entry.entryName}.js`)
|
|
677
|
+
const bytes = await readFile(from).catch(() => undefined)
|
|
678
|
+
if (!bytes) return
|
|
679
|
+
const name = `pnext-client-${assetContentHash(bytes)}.js`
|
|
680
|
+
entry.route.clientEntry = `assets/${name}`
|
|
681
|
+
const to = path.join(outDir, name)
|
|
682
|
+
if (to === from) return
|
|
683
|
+
const sourceMap = `${from}.map`
|
|
684
|
+
if (existsSync(sourceMap)) {
|
|
685
|
+
// The linked map moves with the entry, so the comment has to move too.
|
|
686
|
+
const source = bytes.toString('utf8').replaceAll(`${entry.entryName}.js.map`, `${name}.map`)
|
|
687
|
+
await writeFile(to, source)
|
|
688
|
+
await rm(from, { force: true })
|
|
689
|
+
await rename(sourceMap, `${to}.map`)
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
await rename(from, to)
|
|
693
|
+
}),
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
|
|
660
697
|
interface ClientEntry {
|
|
661
698
|
route: RouteManifestEntry
|
|
662
699
|
entryName: string
|
|
@@ -1905,12 +1942,15 @@ export interface ClientCacheKeyParts {
|
|
|
1905
1942
|
}
|
|
1906
1943
|
|
|
1907
1944
|
/**
|
|
1908
|
-
* The half of the key that is not a walked source:
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1945
|
+
* The half of the key that is not a walked source: pnext's own generation, the generated entry, and
|
|
1946
|
+
* the route's reference list. Cheap to recompute on every lookup - which is what lets the expensive
|
|
1947
|
+
* source walk be validated from a persisted index instead (see dev/restart/client-key.ts).
|
|
1948
|
+
*
|
|
1949
|
+
* The generation, not a list of the pipeline's own files: that list named seven of them and missed
|
|
1950
|
+
* the router runtime the entry pulls in, so editing it left every route's out-dir named for the
|
|
1951
|
+
* bundle it no longer builds.
|
|
1912
1952
|
*/
|
|
1913
|
-
export
|
|
1953
|
+
export function clientCacheStaticHash(
|
|
1914
1954
|
route: RouteManifestEntry,
|
|
1915
1955
|
nextCompat?: boolean,
|
|
1916
1956
|
config?: ResolvedConfig,
|
|
@@ -1919,12 +1959,8 @@ export async function clientCacheStaticHash(
|
|
|
1919
1959
|
// never reads a memo an earlier call left behind.
|
|
1920
1960
|
conventionCache.clear()
|
|
1921
1961
|
const hash = createHash('sha256')
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
hash.update('\0')
|
|
1925
|
-
hash.update(await readText(file))
|
|
1926
|
-
hash.update('\0')
|
|
1927
|
-
}
|
|
1962
|
+
hash.update(frameworkFingerprint())
|
|
1963
|
+
hash.update('\0')
|
|
1928
1964
|
|
|
1929
1965
|
hash.update(
|
|
1930
1966
|
clientEntrySource({
|
|
@@ -1944,6 +1980,11 @@ export async function clientCacheStaticHash(
|
|
|
1944
1980
|
}),
|
|
1945
1981
|
)
|
|
1946
1982
|
hash.update('\0')
|
|
1983
|
+
// The build is scoped to the route id (virtual entry, source pipeline, surface signature, deferred
|
|
1984
|
+
// dynamic href), so it belongs in the key: without it two routes with the same client closure share
|
|
1985
|
+
// an out-dir, and their entries either overwrite each other or serve one another's bytes.
|
|
1986
|
+
hash.update(route.id)
|
|
1987
|
+
hash.update('\0')
|
|
1947
1988
|
hash.update(route.client ? route.file : '')
|
|
1948
1989
|
hash.update(JSON.stringify(route.clientReferences))
|
|
1949
1990
|
return hash.digest('hex')
|
|
@@ -1989,7 +2030,7 @@ export async function clientCacheKeyParts(
|
|
|
1989
2030
|
config?: ResolvedConfig,
|
|
1990
2031
|
staticHash?: string,
|
|
1991
2032
|
): Promise<ClientCacheKeyParts> {
|
|
1992
|
-
const staticDigest = staticHash ??
|
|
2033
|
+
const staticDigest = staticHash ?? clientCacheStaticHash(route, nextCompat, config)
|
|
1993
2034
|
const sources = await Promise.all(
|
|
1994
2035
|
(await clientSourceFiles(route)).map(file => readClientCacheSource(file)),
|
|
1995
2036
|
)
|
|
@@ -2004,18 +2045,6 @@ export async function clientCacheKey(
|
|
|
2004
2045
|
return (await clientCacheKeyParts(route, nextCompat, config)).key
|
|
2005
2046
|
}
|
|
2006
2047
|
|
|
2007
|
-
function clientBuildPipelineFiles() {
|
|
2008
|
-
return [
|
|
2009
|
-
path.join(import.meta.dirname, 'entry.ts'),
|
|
2010
|
-
path.resolve(import.meta.dirname, '..', 'api', 'router.ts'),
|
|
2011
|
-
path.resolve(import.meta.dirname, '..', 'api', 'client-navigation.ts'),
|
|
2012
|
-
path.join(import.meta.dirname, 'react-compiler.ts'),
|
|
2013
|
-
path.join(import.meta.dirname, 'build.ts'),
|
|
2014
|
-
path.resolve(import.meta.dirname, '..', 'compat', 'react', 'client.ts'),
|
|
2015
|
-
path.resolve(import.meta.dirname, '..', 'compat', 'react', 'preact.ts'),
|
|
2016
|
-
].filter(file => existsSync(file))
|
|
2017
|
-
}
|
|
2018
|
-
|
|
2019
2048
|
async function clientSourceFiles(route: RouteManifestEntry) {
|
|
2020
2049
|
const entryFiles = [
|
|
2021
2050
|
...(route.client ? [route.file] : []),
|
package/src/client/entry.ts
CHANGED
|
@@ -139,6 +139,142 @@ function islandVNodeExpr(
|
|
|
139
139
|
: `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A streamed Suspense hole is the one region a live island stops owning: `$RC` replaces the hole's
|
|
144
|
+
* children wholesale before the graft's materialize pass turns the arriving island markers into
|
|
145
|
+
* hosts. Rendering the hole through this component leaves a seam that pass can re-adopt through -
|
|
146
|
+
* INSIDE the parent island's tree, so the new islands keep the parent's whole context chain. Mounting
|
|
147
|
+
* such a host as its own preact root instead severs it from every provider the parent renders.
|
|
148
|
+
*/
|
|
149
|
+
function holeSeamSource() {
|
|
150
|
+
return `
|
|
151
|
+
class PnextHole extends Component {
|
|
152
|
+
componentDidMount() {
|
|
153
|
+
if (this.base) this.base.__pnextHole = this;
|
|
154
|
+
}
|
|
155
|
+
componentWillUnmount() {
|
|
156
|
+
if (this.base && this.base.__pnextHole === this) this.base.__pnextHole = undefined;
|
|
157
|
+
}
|
|
158
|
+
componentWillReceiveProps(next) {
|
|
159
|
+
// A soft navigation re-adopts the parent from the incoming document's DOM;
|
|
160
|
+
// the previous document's grafted content must not outlive its children.
|
|
161
|
+
if (next.children !== this.props.children) this.setState({ adopted: undefined });
|
|
162
|
+
}
|
|
163
|
+
render(props, state) {
|
|
164
|
+
return h('pnext-hole', props.attrs, state.adopted !== undefined ? state.adopted : props.children);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The late-host half of the seam. `adoptHoleSeam` re-renders one hole through its live parent;
|
|
172
|
+
* `claimNestedIslandRoot` is what the island scan calls instead of mounting a nested host.
|
|
173
|
+
*/
|
|
174
|
+
function lateIslandClaimSource() {
|
|
175
|
+
return `
|
|
176
|
+
// The graft ($RC) swapped this hole's DOM wholesale, so re-adopting ALL of it is total: nothing in
|
|
177
|
+
// the hole is still preact-owned. The re-render lands inside the parent island's tree, which is what
|
|
178
|
+
// makes the new islands inherit its context.
|
|
179
|
+
// Detaching an element costs it every piece of state the browser keeps outside the DOM - a lazy
|
|
180
|
+
// image's deferred load observation, an iframe's document, playing media, focus - and re-inserting
|
|
181
|
+
// it does not bring any of that back. moveBefore is the state-preserving move (Chrome 133+); it
|
|
182
|
+
// needs both ends connected and refuses some moves outright, so a plain insert stays the fallback.
|
|
183
|
+
function movePnextNode(parent, node) {
|
|
184
|
+
if (parent.moveBefore && parent.isConnected && node.isConnected) {
|
|
185
|
+
try {
|
|
186
|
+
parent.moveBefore(node, null);
|
|
187
|
+
return;
|
|
188
|
+
} catch (error) {}
|
|
189
|
+
}
|
|
190
|
+
parent.append(node);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A subtree rebuilt from adopted vnodes is made of freshly created elements, and a graft can bring
|
|
194
|
+
// in ones the browser had already deferred. Either way a lazy image can end up in the document with
|
|
195
|
+
// no live intersection observation - no request ever fires, and it stays blank forever. Re-running
|
|
196
|
+
// its source selection re-arms it WITHOUT giving up laziness; one already loading (currentSrc set)
|
|
197
|
+
// or already loaded is left alone, so this never forces a fetch or duplicates one.
|
|
198
|
+
// Drop the capture holder, moving its nodes into \`into\` first when the adoption cannot use them.
|
|
199
|
+
function releaseHoleCapture(hole, into) {
|
|
200
|
+
const captured = hole.__pnextCaptured;
|
|
201
|
+
if (!captured) return;
|
|
202
|
+
hole.__pnextCaptured = undefined;
|
|
203
|
+
if (into) for (const node of [...captured.childNodes]) movePnextNode(into, node);
|
|
204
|
+
captured.remove();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rekickLazyLoadables(root) {
|
|
208
|
+
for (const img of root.querySelectorAll('img[loading=lazy]')) {
|
|
209
|
+
if (img.complete || img.currentSrc) continue;
|
|
210
|
+
for (const name of ['src', 'srcset']) {
|
|
211
|
+
const value = img.getAttribute(name);
|
|
212
|
+
if (value === null) continue;
|
|
213
|
+
img.removeAttribute(name);
|
|
214
|
+
img.setAttribute(name, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function adoptHoleSeam(hole) {
|
|
220
|
+
const pending = hole.__pnextAdopting;
|
|
221
|
+
if (pending) return pending;
|
|
222
|
+
const seam = hole.__pnextHole;
|
|
223
|
+
// No seam ever appeared: put the captured nodes back rather than losing them.
|
|
224
|
+
if (!seam) return releaseHoleCapture(hole, hole);
|
|
225
|
+
const adopting = (async () => {
|
|
226
|
+
const captured = hole.__pnextCaptured;
|
|
227
|
+
const children = await domChildren(captured ?? hole);
|
|
228
|
+
// Preact rebuilds the subtree from the adopted vnodes, so the server nodes they were read from
|
|
229
|
+
// would survive as unowned duplicates. Clearing and re-rendering within one task keeps the swap
|
|
230
|
+
// inside a single frame - preact's rerender is a microtask, so no paint sees the empty hole.
|
|
231
|
+
releaseHoleCapture(hole);
|
|
232
|
+
hole.replaceChildren();
|
|
233
|
+
seam.setState({ adopted: children ?? null }, () => rekickLazyLoadables(hole));
|
|
234
|
+
})();
|
|
235
|
+
hole.__pnextAdopting = adopting;
|
|
236
|
+
try {
|
|
237
|
+
await adopting;
|
|
238
|
+
} finally {
|
|
239
|
+
if (hole.__pnextAdopting === adopting) hole.__pnextAdopting = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A nested host belongs to its parent island's DOM adoption pass, never to a mount of its own. Wait
|
|
244
|
+
// out an adoption already in flight (its walk may still reach this host), and claim only what the
|
|
245
|
+
// parent can no longer see - a host the graft materialized after the parent went live - through the
|
|
246
|
+
// hole seam that owns the grafted DOM. A host under a parent that has not mounted yet stays put.
|
|
247
|
+
async function claimNestedIslandRoot(root, parent) {
|
|
248
|
+
// The innermost hole whose seam is live owns this host. A nearer hole with no seam is raw grafted
|
|
249
|
+
// DOM inside an outer seam's region, so that outer one is the tree to re-adopt through.
|
|
250
|
+
let hole;
|
|
251
|
+
for (let node = root.parentElement; node && node !== parent; node = node.parentElement) {
|
|
252
|
+
if (node.localName !== 'pnext-hole') continue;
|
|
253
|
+
if (node.__pnextHole) { hole = node; break; }
|
|
254
|
+
hole ??= node;
|
|
255
|
+
}
|
|
256
|
+
// The parent is mid-adoption and has no seam here yet: its walk may already have snapshotted this
|
|
257
|
+
// hole's fallback, and its hydration then drops the DOM the graft just landed. Park the grafted
|
|
258
|
+
// nodes synchronously - ahead of that render - and adopt them from the holder once the tree is
|
|
259
|
+
// live. A walk that had NOT reached the hole yet simply adopts an empty one and gets the same
|
|
260
|
+
// content back through the seam. The holder is a hidden ELEMENT rather than a fragment: a
|
|
261
|
+
// disconnected parent is exactly what strips browser state, and moveBefore refuses one.
|
|
262
|
+
if (hole && !hole.__pnextHole && !hole.__pnextCaptured && parent.__pnextMounting) {
|
|
263
|
+
const holder = document.createElement('pnext-hole-capture');
|
|
264
|
+
holder.hidden = true;
|
|
265
|
+
document.body.append(holder);
|
|
266
|
+
hole.__pnextCaptured = holder;
|
|
267
|
+
for (const node of [...hole.childNodes]) movePnextNode(holder, node);
|
|
268
|
+
}
|
|
269
|
+
// A parent whose module failed still settles: the claim has to release its capture rather than
|
|
270
|
+
// leave the grafted nodes parked in a holder nobody will ever adopt from.
|
|
271
|
+
await parent.__pnextMounting?.catch(() => {});
|
|
272
|
+
if (!hole || (!hole.__pnextCaptured && !root.isConnected)) return;
|
|
273
|
+
return adoptHoleSeam(hole);
|
|
274
|
+
}
|
|
275
|
+
`
|
|
276
|
+
}
|
|
277
|
+
|
|
142
278
|
// Two mount passes can reach the same island root concurrently - mountRoute and the streamed-Suspense
|
|
143
279
|
// re-scan both walk the document, and their "already live" guard runs BEFORE mountIsland awaits the
|
|
144
280
|
// component module. Without a synchronous claim both passes mount: the first tree is orphaned over the
|
|
@@ -291,6 +427,22 @@ import { isActionError } from ${JSON.stringify(actionClientModulePath())};`,
|
|
|
291
427
|
// The `$$pnextError` marker is written by the renderer's action-prop serializer, which returns props
|
|
292
428
|
// untouched when next compat is off - so core and react entries import the ref+binary reviver and the
|
|
293
429
|
// error branch never ships.
|
|
430
|
+
/**
|
|
431
|
+
* Both wire carriers - an island's `data-pnext-props` markers and the client page's
|
|
432
|
+
* `__PNEXT_PROPS__` - must rebuild the same legacy sync surface, so they share one rebuilder. The
|
|
433
|
+
* server stamps the flag only under compat.next.legacyRequestAPIs; a strict app reads params the
|
|
434
|
+
* async way and must not grow a surface its server render never had.
|
|
435
|
+
*
|
|
436
|
+
* Emitted under next compat only - the flag rides compat's document scripts, so a core/react entry
|
|
437
|
+
* could never see it and ships neither the import nor the call.
|
|
438
|
+
*/
|
|
439
|
+
function syncPropsHelperSource() {
|
|
440
|
+
return `
|
|
441
|
+
import { rebuildSyncProps as __pnextRebuildSyncProps } from ${JSON.stringify(serializeModulePath())};
|
|
442
|
+
const __pnextSyncProps = () => (typeof window !== 'undefined' && window.__PNEXT_SYNC_PROPS__ ? __pnextRebuildSyncProps : undefined);
|
|
443
|
+
`
|
|
444
|
+
}
|
|
445
|
+
|
|
294
446
|
function propsParserSource(nextCompat?: boolean) {
|
|
295
447
|
const revive = nextCompat ? 'reviveSerializedErrorRefs' : 'reviveSerializedRefs'
|
|
296
448
|
return `
|
|
@@ -322,7 +474,7 @@ function parseIslandProps(raw) {
|
|
|
322
474
|
// nested in plain containers (a dehydrated react-query state's pending-query promise). Revive both
|
|
323
475
|
// into pre-fulfilled promises - see revivePromiseMarkers. The deep walk is gated on a substring
|
|
324
476
|
// test of the raw attribute, so an island with no promise props pays one indexOf.
|
|
325
|
-
return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived) : revived;
|
|
477
|
+
return __pnextHasPromiseProps(raw || '') ? __pnextRevivePromiseMarkers(revived${nextCompat ? ', __pnextSyncProps()' : ''}) : revived;
|
|
326
478
|
}
|
|
327
479
|
`
|
|
328
480
|
}
|
|
@@ -501,15 +653,21 @@ function keepsMountedRoot(root, keep) {
|
|
|
501
653
|
// The per-route half of the lifecycle, closing over the route's own mount
|
|
502
654
|
// state. mountRoute runs the steps the route actually has: an island scan, the
|
|
503
655
|
// client-shell hydration, the client page.
|
|
504
|
-
function routeLifecycleSource({ nextCompat, islands, page, shell }: ClientRuntimeFacts) {
|
|
656
|
+
function routeLifecycleSource({ nextCompat, islands, page, shell, dev }: ClientRuntimeFacts) {
|
|
505
657
|
const gate = nextCompat
|
|
506
658
|
? ' pnextGate(markRouteHydrated, pnextIslandMounts, pnextVisibleMount);\n'
|
|
507
659
|
: ''
|
|
660
|
+
const islandMounts = dev
|
|
661
|
+
? `pnextIslandScan.done.then(() => {
|
|
662
|
+
diagnoseSkippedIslands();
|
|
663
|
+
return pnextIslandScan.mounted;
|
|
664
|
+
})`
|
|
665
|
+
: 'pnextIslandScan.done.then(() => pnextIslandScan.mounted)'
|
|
508
666
|
const mountBody = [
|
|
509
667
|
islands
|
|
510
668
|
? ` if (islands.length > 0) {
|
|
511
669
|
pnextIslandScan = scanIslands();
|
|
512
|
-
pnextIslandMounts =
|
|
670
|
+
pnextIslandMounts = ${islandMounts};
|
|
513
671
|
}`
|
|
514
672
|
: '',
|
|
515
673
|
shell ? ' if (shell) pnextVisibleMount = hydrateClientShell();' : '',
|
|
@@ -639,6 +797,8 @@ ${overlay}installClientErrors({ errorComponent: ${
|
|
|
639
797
|
* ships only when some route in the app can actually use it.
|
|
640
798
|
*/
|
|
641
799
|
export interface ClientRuntimeFacts {
|
|
800
|
+
/** Development-only diagnostics; omitted entirely from production bundles. */
|
|
801
|
+
dev?: boolean
|
|
642
802
|
nextCompat?: boolean
|
|
643
803
|
/** Any route mounts islands (island scan, revival, static children). */
|
|
644
804
|
islands?: boolean
|
|
@@ -694,11 +854,12 @@ export function clientRuntimeSource(facts: ClientRuntimeFacts) {
|
|
|
694
854
|
// it revives island DOM exactly like an island mount does.
|
|
695
855
|
const revival = Boolean(facts.islands || shell)
|
|
696
856
|
return `
|
|
697
|
-
import { h, hydrate, render } from 'preact';
|
|
857
|
+
import { ${revival ? 'Component, ' : ''}h, hydrate, render } from 'preact';
|
|
698
858
|
${nextCompat ? "import { options as __pnextPreactOptions } from 'preact';" : ''}
|
|
699
859
|
${deferredIslands ? "import { useEffect, useState } from 'preact/hooks';" : ''}
|
|
700
860
|
${suspense ? "import { Suspense } from 'preact/compat';" : ''}
|
|
701
861
|
${revival ? layoutSegmentHelperSource(nextCompat) : ''}
|
|
862
|
+
${nextCompat && (revival || page) ? syncPropsHelperSource() : ''}
|
|
702
863
|
${revival ? propsParserSource(nextCompat) : ''}
|
|
703
864
|
${revival ? islandHelpersSource(facts) : ''}
|
|
704
865
|
${islandCssHelperSource()}
|
|
@@ -918,6 +1079,7 @@ const pnextMountedRoots = new Set();
|
|
|
918
1079
|
let pnextIslandMounts;
|
|
919
1080
|
let pnextRender;
|
|
920
1081
|
|
|
1082
|
+
${nextCompat ? syncPropsHelperSource() : ''}
|
|
921
1083
|
${propsParserSource(nextCompat)}
|
|
922
1084
|
${compatBoundary}
|
|
923
1085
|
export function mountRoute() {
|
|
@@ -1119,6 +1281,10 @@ function domProps(element) {
|
|
|
1119
1281
|
return props;
|
|
1120
1282
|
}
|
|
1121
1283
|
|
|
1284
|
+
// A nested root belongs to its parent island's DOM adoption pass, never to a
|
|
1285
|
+
// mount of its own. This entry carries no streamed-hole seam (see the shared
|
|
1286
|
+
// runtime): its whole point is a preact-free first paint, and a host grafted in
|
|
1287
|
+
// after a visible island mounted would be dead weight in every bundle.
|
|
1122
1288
|
function isNestedIslandRoot(root) {
|
|
1123
1289
|
return root.parentElement?.closest('pnext-client') != null;
|
|
1124
1290
|
}
|
|
@@ -1195,16 +1361,21 @@ const rawProps = window.__PNEXT_PROPS__ ?? {};
|
|
|
1195
1361
|
// Pre-fulfilled promises (status/value readable synchronously): use() must
|
|
1196
1362
|
// not suspend during page hydration or preact appends fragment siblings
|
|
1197
1363
|
// instead of reusing the server DOM.
|
|
1198
|
-
|
|
1364
|
+
// Under compat.next.legacyRequestAPIs the server also gave these promises the resolved object's
|
|
1365
|
+
// own keys (withSyncProps), so Next-14 code reads \`params.creator\` without await; rebuild that
|
|
1366
|
+
// same surface here or the hydrated page loses every param the SSR render had.
|
|
1367
|
+
${nextCompat ? 'const legacy = __pnextSyncProps();' : ''}
|
|
1368
|
+
const fulfilled = (kind, value) => {
|
|
1199
1369
|
const promise = Promise.resolve(value);
|
|
1200
1370
|
promise.status = 'fulfilled';
|
|
1201
1371
|
promise.value = value;
|
|
1202
|
-
return promise;
|
|
1372
|
+
return ${nextCompat ? 'legacy ? legacy(promise, kind, value) : promise' : 'promise'};
|
|
1203
1373
|
};
|
|
1204
1374
|
return {
|
|
1205
1375
|
...rawProps,
|
|
1206
|
-
params: fulfilled(rawProps.params ?? {}),
|
|
1376
|
+
params: fulfilled('params', rawProps.params ?? {}),
|
|
1207
1377
|
searchParams: fulfilled(
|
|
1378
|
+
'searchParams',
|
|
1208
1379
|
rawProps.searchParams ?? Object.fromEntries(new URLSearchParams(location.search)),
|
|
1209
1380
|
),
|
|
1210
1381
|
};
|
|
@@ -1320,9 +1491,39 @@ function clientBoundaryHelperSource(nextCompat?: boolean, suspense?: boolean) {
|
|
|
1320
1491
|
`
|
|
1321
1492
|
}
|
|
1322
1493
|
|
|
1323
|
-
function hydrateIslandsSource({
|
|
1494
|
+
function hydrateIslandsSource({
|
|
1495
|
+
nextCompat,
|
|
1496
|
+
visibleIslands,
|
|
1497
|
+
deferredIslands,
|
|
1498
|
+
dev,
|
|
1499
|
+
}: ClientRuntimeFacts) {
|
|
1500
|
+
const diagnostic = dev
|
|
1501
|
+
? `
|
|
1502
|
+
function diagnoseSkippedIslands() {
|
|
1503
|
+
for (const root of document.querySelectorAll('pnext-client[data-pnext-client]')) {
|
|
1504
|
+
if (root.__pnextLive) continue;
|
|
1505
|
+
const id = root.getAttribute('data-pnext-client');
|
|
1506
|
+
const island = id ? islandById.get(id) : undefined;
|
|
1507
|
+
const parent = nestedIslandParent(root);
|
|
1508
|
+
// A visible island is deliberately inert until intersection. A nested
|
|
1509
|
+
// island whose parent is still pending is likewise owned by that parent's
|
|
1510
|
+
// adoption pass, one parked in a capture holder is mid-adoption, and one the
|
|
1511
|
+
// claim pass adopted is no longer in the document. Everything else is a host
|
|
1512
|
+
// this route can never hydrate.
|
|
1513
|
+
if (island?.options?.load === 'visible') continue;
|
|
1514
|
+
if (root.closest('pnext-hole-capture')) continue;
|
|
1515
|
+
if (parent && !parent.__pnextLive) continue;
|
|
1516
|
+
const reason = island
|
|
1517
|
+
? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
|
|
1518
|
+
: 'has no entry in the client island manifest';
|
|
1519
|
+
console.warn('pnext: client island ' + (id ?? '<missing id>') + ' ' + reason);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
`
|
|
1523
|
+
: ''
|
|
1324
1524
|
return `
|
|
1325
1525
|
const islandById = new Map(islands.map(island => [island.id, island]));
|
|
1526
|
+
${lateIslandClaimSource()}${diagnostic}
|
|
1326
1527
|
|
|
1327
1528
|
// The mounted count is known as soon as the scan loop finishes, before any
|
|
1328
1529
|
// island module resolves — so the client shell can decide whether it owns the
|
|
@@ -1341,7 +1542,11 @@ for (const island of islands) {
|
|
|
1341
1542
|
}
|
|
1342
1543
|
for (const island of islands) {
|
|
1343
1544
|
for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
|
|
1344
|
-
|
|
1545
|
+
const parent = nestedIslandParent(root);
|
|
1546
|
+
if (parent) {
|
|
1547
|
+
pending.push(claimNestedIslandRoot(root, parent));
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1345
1550
|
// Already mounted (e.g. a re-scan after a streamed Suspense hole materialized new islands):
|
|
1346
1551
|
// mount() stamps __pnextLive, so skipping these leaves the shell's live islands untouched. A root
|
|
1347
1552
|
// preserved across a soft navigation is ALSO live, but carries the incoming placeholder (fresh SSR
|
|
@@ -1453,6 +1658,10 @@ async function domNode(node) {
|
|
|
1453
1658
|
|
|
1454
1659
|
const element = node;
|
|
1455
1660
|
if (element.localName === ${JSON.stringify(ISLAND_BOUNDARY_ERROR_ELEMENT)}) return pnextBoundaryErrorVNode(element);
|
|
1661
|
+
// The seam a late streamed graft re-adopts through (see holeSeamSource).
|
|
1662
|
+
if (element.localName === 'pnext-hole') {
|
|
1663
|
+
return h(PnextHole, { attrs: domProps(element) }, await domChildren(element));
|
|
1664
|
+
}
|
|
1456
1665
|
if (element.localName === 'pnext-client') {
|
|
1457
1666
|
const id = element.getAttribute('data-pnext-client');
|
|
1458
1667
|
const island = id ? islandById.get(id) : undefined;
|
|
@@ -1499,12 +1708,15 @@ function domProps(element) {
|
|
|
1499
1708
|
return props;
|
|
1500
1709
|
}
|
|
1501
1710
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1711
|
+
// The island host that owns this root's DOM, if any. A nested root is never
|
|
1712
|
+
// mounted here: the parent adopts it, or claimNestedIslandRoot routes it through
|
|
1713
|
+
// the parent's hole seam.
|
|
1714
|
+
function nestedIslandParent(root) {
|
|
1715
|
+
return root.parentElement?.closest('pnext-client');
|
|
1504
1716
|
}
|
|
1505
|
-
${
|
|
1506
|
-
|
|
1507
|
-
|
|
1717
|
+
${holeSeamSource()}${
|
|
1718
|
+
visibleIslands
|
|
1719
|
+
? `
|
|
1508
1720
|
function visibleTarget(root) {
|
|
1509
1721
|
// The host's OWN box: hostMeasureSource delegates the patched one to the
|
|
1510
1722
|
// parent, and IntersectionObserver never fires for a display:contents target.
|
|
@@ -1513,8 +1725,8 @@ function visibleTarget(root) {
|
|
|
1513
1725
|
return root.parentElement ?? root;
|
|
1514
1726
|
}
|
|
1515
1727
|
`
|
|
1516
|
-
|
|
1517
|
-
}${mountOnceSource()}${
|
|
1728
|
+
: ''
|
|
1729
|
+
}${mountOnceSource()}${
|
|
1518
1730
|
deferredIslands
|
|
1519
1731
|
? `
|
|
1520
1732
|
function DeferredIsland({ load, props, children }) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
type PageSlotRange = [start: Element | Comment, end: Comment | undefined, nodes: ChildNode[]]
|
|
2
|
+
|
|
3
|
+
/** Resolve either wire form of a page slot inside a preserved server segment. */
|
|
4
|
+
export function pageSlotRange(segment: Element): PageSlotRange | null {
|
|
5
|
+
const element = segment.querySelector('#pnext-page')
|
|
6
|
+
if (element) return [element, undefined, [...element.childNodes]]
|
|
7
|
+
const walker = document.createTreeWalker(segment, 128 /* NodeFilter.SHOW_COMMENT */)
|
|
8
|
+
while (walker.nextNode()) {
|
|
9
|
+
const start = walker.currentNode as Comment
|
|
10
|
+
if (!start.data.startsWith('pnext-page:')) continue
|
|
11
|
+
const nodes: ChildNode[] = []
|
|
12
|
+
let end = start.nextSibling
|
|
13
|
+
while (end && !(end.nodeType === 8 && end.nodeValue === '/pnext-page')) {
|
|
14
|
+
nodes.push(end)
|
|
15
|
+
end = end.nextSibling
|
|
16
|
+
}
|
|
17
|
+
return end ? [start, end as Comment, nodes] : null
|
|
18
|
+
}
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether an element belongs to the page slot in either DOM form. */
|
|
23
|
+
export function elementInPageSlot(element: Element): boolean {
|
|
24
|
+
return !!(
|
|
25
|
+
element.closest('#pnext-page') ||
|
|
26
|
+
pageSlotRange(document.body)?.[2].some(node => node.contains(element))
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Graft a destination page into a preserved layout without changing the destination's mount shape.
|
|
32
|
+
* Client pages need the real `#pnext-page` element; server/island-only pages need the marker range.
|
|
33
|
+
*/
|
|
34
|
+
export function graftPageSlot(liveSegment: Element, incomingSegment: Element): boolean {
|
|
35
|
+
const live = pageSlotRange(liveSegment)
|
|
36
|
+
const incoming = pageSlotRange(incomingSegment)
|
|
37
|
+
if (!live || !incoming) return false
|
|
38
|
+
const nodes = reusePageNodes(live[2], incoming[2])
|
|
39
|
+
for (const stale of live[2]) stale.remove()
|
|
40
|
+
const end = incoming[1]
|
|
41
|
+
if (!end) (incoming[0] as Element).replaceChildren(...nodes)
|
|
42
|
+
else {
|
|
43
|
+
for (const stale of incoming[2]) stale.remove()
|
|
44
|
+
}
|
|
45
|
+
live[0].replaceWith(incoming[0])
|
|
46
|
+
if (end) incoming[0].after(...nodes, end)
|
|
47
|
+
live[1]?.remove()
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Next re-renders a page in place, so unchanged DOM keeps its node identity.
|
|
52
|
+
function reusePageNodes(live: readonly ChildNode[], incoming: readonly Node[]): Node[] {
|
|
53
|
+
const candidates = live.filter(
|
|
54
|
+
(node): node is Element =>
|
|
55
|
+
node instanceof Element &&
|
|
56
|
+
!node.matches('script, pnext-client, #pnext-page') &&
|
|
57
|
+
!node.querySelector('pnext-client, #pnext-page, [data-pnext-preserve]'),
|
|
58
|
+
)
|
|
59
|
+
return incoming.map(node => {
|
|
60
|
+
if (!(node instanceof Element)) return node
|
|
61
|
+
const index = candidates.findIndex(candidate => candidate.outerHTML === node.outerHTML)
|
|
62
|
+
return index === -1 ? node : candidates.splice(index, 1)[0]!
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LoadingShellTarget {
|
|
67
|
+
container: HTMLElement
|
|
68
|
+
markerRange?: PageSlotRange
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve a safe loading-shell target without falling through a dissolved page to the body. */
|
|
72
|
+
export function loadingShellTarget(): LoadingShellTarget {
|
|
73
|
+
const page = document.getElementById('pnext-page')
|
|
74
|
+
if (page) return { container: page }
|
|
75
|
+
const markerRange = pageSlotRange(document.body)
|
|
76
|
+
if (markerRange?.[1]) {
|
|
77
|
+
return { container: markerRange[0].parentElement!, markerRange }
|
|
78
|
+
}
|
|
79
|
+
const root = document.querySelector<HTMLElement>('[data-pnext-root]')
|
|
80
|
+
return { container: root ?? document.body }
|
|
81
|
+
}
|