@wular/pnext 0.1.4 → 0.1.5
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/config/ts/react.json +2 -1
- package/package.json +1 -1
- package/reference/navigation.md +3 -1
- package/src/api/client-navigation.ts +53 -4
- package/src/api/link.tsx +6 -7
- package/src/cli/adapters/vercel-warm.ts +6 -1
- package/src/cli/adapters/vercel.ts +1 -1
- package/src/cli/build/cache.ts +8 -3
- package/src/cli/build.ts +3 -2
- package/src/cli/serve/pipeline.ts +11 -3
- package/src/cli/typegen.ts +65 -3
- package/src/client/build.ts +10 -7
- package/src/client/entry.ts +50 -29
- package/src/client/reference-stub.ts +1 -1
- package/src/client/router/hub.ts +13 -11
- package/src/client/router/page-slot.ts +58 -0
- package/src/client/router/runtime.ts +377 -388
- package/src/compat/bundler/symlink-imports.ts +1 -1
- package/src/compat/client/errors/bare-boundary.ts +1 -1
- package/src/compat/client/errors/error-boundary.ts +1 -1
- package/src/compat/client/errors/global-error.ts +40 -79
- package/src/compat/client/errors/install.ts +1 -1
- package/src/compat/client/errors/lazy.ts +7 -0
- package/src/compat/client/errors/primitive-throw.ts +36 -53
- package/src/compat/client/segment-cache-policy.ts +5 -22
- package/src/compat/client/segment-cache.ts +7 -26
- package/src/compat/css/modules.ts +1 -7
- package/src/compat/css/sass.ts +1 -7
- package/src/compat/react/parity.ts +1 -1
- package/src/compat/register/bundler.ts +9 -1
- package/src/compat/tsconfig-defaults.ts +4 -7
- package/src/compat-bootstrap.ts +15 -0
- package/src/css/build.ts +18 -15
- package/src/dev/server.ts +2 -2
- package/src/index.ts +2 -0
- package/src/render/renderer.ts +209 -24
- package/src/render/resource-hints.ts +4 -1
- package/src/resolve/imports.ts +13 -8
- package/src/routing/routes.ts +32 -18
- package/src/runtime/loader.ts +29 -10
- package/src/runtime/modules.ts +28 -7
- package/src/runtime/vendor-build.ts +2 -2
- package/src/types.ts +2 -0
- package/src/utils/fs.ts +122 -0
package/config/ts/react.json
CHANGED
package/package.json
CHANGED
package/reference/navigation.md
CHANGED
|
@@ -92,7 +92,9 @@ Exported from `@wular/pnext/navigation/client`, for use in components marked `"u
|
|
|
92
92
|
| `useSearchParams()` | Read-only URL search params. |
|
|
93
93
|
| `useParams<Route>()` | Current route params from the initial route state. |
|
|
94
94
|
| `useRoute<Route>()` | Pathname, route template, params, and search params together. |
|
|
95
|
-
| `useLinkStatus()` |
|
|
95
|
+
| `useLinkStatus()` | `{ pending }` for the enclosing `<Link>`. |
|
|
96
|
+
|
|
97
|
+
`useLinkStatus()` tracks the pending state of a `<Link>`, so call it in a component rendered inside one. `pending` turns true when the link is clicked and false once its navigation paints a loading state or the page, fails, or another navigation replaces it.
|
|
96
98
|
|
|
97
99
|
Router pushes and replaces are typed like links. They soft-navigate when the path or query changes, and fall back to the History API for same-URL hash updates. Cross-origin targets become a full page load. Refreshing re-fetches the current route in place.
|
|
98
100
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { options } from 'preact'
|
|
1
2
|
import { useEffect, useState } from 'preact/hooks'
|
|
2
3
|
import { ReadonlyURLSearchParams, type HrefOptions } from './navigation'
|
|
3
4
|
import { blockJavascriptUrl, prefetchRoute, softNavigate } from '../client/router'
|
|
5
|
+
import { routerState } from '../client/router/hub'
|
|
4
6
|
import { seedHistoryEntry } from '../client/router/history'
|
|
5
7
|
import {
|
|
6
8
|
emitLocationChange,
|
|
@@ -158,8 +160,31 @@ export function useRoute<Route extends RoutePath = RoutePath>(): CurrentRoute<Ro
|
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
interface HookOptions {
|
|
164
|
+
__h?: (component: { __P?: Element }, ...rest: unknown[]) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
161
167
|
export function useLinkStatus() {
|
|
162
|
-
|
|
168
|
+
// The router marks a link pending through its anchor, so find the Link this component renders in.
|
|
169
|
+
const hookOptions = options as HookOptions
|
|
170
|
+
const hook = hookOptions.__h
|
|
171
|
+
let parent: Element | undefined
|
|
172
|
+
hookOptions.__h = (component, ...rest) => {
|
|
173
|
+
parent = component.__P
|
|
174
|
+
hook?.(component, ...rest)
|
|
175
|
+
}
|
|
176
|
+
const [pending, setPending] = useState(false)
|
|
177
|
+
hookOptions.__h = hook
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const link = parent?.closest<HTMLAnchorElement & { __pnextHints?: Set<typeof setPending> }>(
|
|
180
|
+
'a[data-pnext-link]',
|
|
181
|
+
)
|
|
182
|
+
if (!link) return
|
|
183
|
+
const hints = (link.__pnextHints ??= new Set())
|
|
184
|
+
hints.add(setPending)
|
|
185
|
+
return () => hints.delete(setPending)
|
|
186
|
+
}, [])
|
|
187
|
+
return { pending }
|
|
163
188
|
}
|
|
164
189
|
|
|
165
190
|
function useLocation() {
|
|
@@ -196,11 +221,33 @@ function locationSignature(location: { pathname: string; search: string; hash: s
|
|
|
196
221
|
return `${location.pathname}${location.search}${location.hash}`
|
|
197
222
|
}
|
|
198
223
|
|
|
224
|
+
// Server only: a request/context store, anchored on globalThis.
|
|
225
|
+
interface ServerStore {
|
|
226
|
+
request?: { url: string }
|
|
227
|
+
params?: Record<string, RouteParamValue>
|
|
228
|
+
routeMode?: string
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function serverStore(key: string) {
|
|
232
|
+
const storage = (globalThis as Record<symbol, { getStore(): ServerStore | undefined }>)[
|
|
233
|
+
Symbol.for(key)
|
|
234
|
+
]
|
|
235
|
+
return storage?.getStore()
|
|
236
|
+
}
|
|
237
|
+
|
|
199
238
|
function currentLocation() {
|
|
200
239
|
if (!process.browser && typeof window === 'undefined') {
|
|
240
|
+
const href = serverStore('pnext.requestStorage')?.request?.url
|
|
241
|
+
const url = href ? new URL(href) : undefined
|
|
201
242
|
return {
|
|
202
|
-
pathname: '/',
|
|
203
|
-
|
|
243
|
+
pathname: url?.pathname ?? '/',
|
|
244
|
+
// Like Next: dev and dynamic renders see the query; static and cached HTML must not bake it in.
|
|
245
|
+
search:
|
|
246
|
+
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
247
|
+
process.env.NODE_ENV === 'development' ||
|
|
248
|
+
serverStore('pnext.workUnitStorage')?.routeMode === 'dynamic'
|
|
249
|
+
? (url?.search ?? '')
|
|
250
|
+
: '',
|
|
204
251
|
hash: '',
|
|
205
252
|
}
|
|
206
253
|
}
|
|
@@ -212,7 +259,8 @@ function currentLocation() {
|
|
|
212
259
|
}
|
|
213
260
|
|
|
214
261
|
function routeState() {
|
|
215
|
-
if (!process.browser && typeof window === 'undefined')
|
|
262
|
+
if (!process.browser && typeof window === 'undefined')
|
|
263
|
+
return { params: serverStore('pnext.requestStorage')?.params } as RouteState
|
|
216
264
|
const state = (history.state as Record<string, unknown> | null) ?? {}
|
|
217
265
|
const routeState = window.__PNEXT_ROUTE__ ?? {}
|
|
218
266
|
const bfcacheId = typeof state.__pnextBfcacheId === 'string' ? state.__pnextBfcacheId : undefined
|
|
@@ -242,6 +290,7 @@ function navigate(url: string, mode: 'push' | 'replace', scroll = true) {
|
|
|
242
290
|
if (mode === 'replace') window.history.replaceState(history.state, '', next)
|
|
243
291
|
else window.history.pushState(history.state, '', next)
|
|
244
292
|
scheduleNavigationScroll(next, { scroll })
|
|
293
|
+
routerState.settleLink?.()
|
|
245
294
|
}
|
|
246
295
|
|
|
247
296
|
// Exported so compat's link-status can guarantee raw history.pushState /
|
package/src/api/link.tsx
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import { h, type ComponentChildren, type JSX } from 'preact'
|
|
3
3
|
import { useEffect, useRef } from 'preact/hooks'
|
|
4
|
-
import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react'
|
|
5
4
|
import { getDefaultPrefetchMode, routeHref, type SearchInput } from '../routing/href'
|
|
6
5
|
import type { PrefetchMode, RouteParams, RoutePath } from '../types'
|
|
7
6
|
|
|
@@ -9,9 +8,9 @@ export interface NavigateEvent {
|
|
|
9
8
|
preventDefault(): void
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
type LinkMouseEvent =
|
|
11
|
+
type LinkMouseEvent = JSX.TargetedMouseEvent<HTMLAnchorElement>
|
|
13
12
|
|
|
14
|
-
type BaseLinkProps = Omit<
|
|
13
|
+
type BaseLinkProps = Omit<JSX.IntrinsicElements['a'], 'href' | 'onClick' | 'ref'> & {
|
|
15
14
|
prefetch?: PrefetchMode
|
|
16
15
|
replace?: boolean
|
|
17
16
|
scroll?: boolean
|
|
@@ -21,7 +20,7 @@ type BaseLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'onC
|
|
|
21
20
|
onNavigate?: (event: NavigateEvent) => void
|
|
22
21
|
onPrefetchStart?: () => void
|
|
23
22
|
onPrefetchFinish?: () => void
|
|
24
|
-
children?:
|
|
23
|
+
children?: ComponentChildren
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
type ParamsProp<Route extends RoutePath> =
|
|
@@ -48,7 +47,7 @@ export function Link<Route extends RoutePath>({
|
|
|
48
47
|
onPrefetchFinish,
|
|
49
48
|
children,
|
|
50
49
|
...anchorProps
|
|
51
|
-
}: LinkProps<Route>):
|
|
50
|
+
}: LinkProps<Route>): ComponentChildren {
|
|
52
51
|
const resolvedHref = routeHref(href, { params: params ?? {}, search, hash })
|
|
53
52
|
// A link's own prefetch prop wins; otherwise the app-wide config default.
|
|
54
53
|
const prefetchMode = prefetch ?? getDefaultPrefetchMode()
|
|
@@ -72,8 +71,8 @@ export function Link<Route extends RoutePath>({
|
|
|
72
71
|
if (navEvent.defaultPrevented) clickEvent.preventDefault()
|
|
73
72
|
},
|
|
74
73
|
} as JSX.HTMLAttributes<HTMLAnchorElement>,
|
|
75
|
-
children
|
|
76
|
-
)
|
|
74
|
+
children,
|
|
75
|
+
)
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
// The prefetch runtime dispatches lifecycle events on the anchor that
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveManifest } from '../../utils/fs'
|
|
1
2
|
/**
|
|
2
3
|
* Module warm-up for a production build - run in its own process.
|
|
3
4
|
*
|
|
@@ -549,7 +550,11 @@ if (import.meta.main) {
|
|
|
549
550
|
// only thing on stderr.
|
|
550
551
|
const manifestFile = path.join(config.outPath, 'manifest.json')
|
|
551
552
|
if (!request.skip || !existsSync(manifestFile)) process.exit(0)
|
|
552
|
-
const manifest =
|
|
553
|
+
const manifest = resolveManifest(
|
|
554
|
+
JSON.parse(await readFile(manifestFile, 'utf8')) as BuildManifest,
|
|
555
|
+
config.outPath,
|
|
556
|
+
config.root,
|
|
557
|
+
)
|
|
553
558
|
await warmRouteModules(config, manifest, new Set(request.skip ?? []), mode)
|
|
554
559
|
// App code reached through a route handler can leave the loop alive (a db
|
|
555
560
|
// pool, a stray interval); the warm pass is done either way.
|
|
@@ -1012,7 +1012,7 @@ async function traceNodeModulesClosure(
|
|
|
1012
1012
|
// packages - workspace source gets traced further. Bare package specifiers are also recorded
|
|
1013
1013
|
// so the closure ships (or workspace-links) their node_modules entries; workspace packages
|
|
1014
1014
|
// need the link even when their source is traced.
|
|
1015
|
-
const resolved = resolveImport(config.root, file, specifier)
|
|
1015
|
+
const resolved = resolveImport(config.root, file, specifier, config.workspaceRoot)
|
|
1016
1016
|
if (
|
|
1017
1017
|
resolved &&
|
|
1018
1018
|
isInsideDir(workspaceRoot, resolved) &&
|
package/src/cli/build/cache.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveManifest } from '../../utils/fs'
|
|
1
2
|
import { createHash } from 'node:crypto'
|
|
2
3
|
import { existsSync } from 'node:fs'
|
|
3
4
|
import { lstat, mkdir, readFile, readdir, readlink } from 'node:fs/promises'
|
|
@@ -73,9 +74,13 @@ export async function lookupBuildCache(
|
|
|
73
74
|
return { enabled: true, context, inputs, reason: `output (${changed || 'unknown'})` }
|
|
74
75
|
}
|
|
75
76
|
try {
|
|
76
|
-
const manifest =
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const manifest = resolveManifest(
|
|
78
|
+
JSON.parse(
|
|
79
|
+
await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
|
|
80
|
+
) as BuildManifest,
|
|
81
|
+
config.outPath,
|
|
82
|
+
config.root,
|
|
83
|
+
)
|
|
79
84
|
return { enabled: true, context, inputs, manifest, reason: 'hit' }
|
|
80
85
|
} catch {
|
|
81
86
|
return { enabled: true, context, inputs, reason: 'manifest' }
|
package/src/cli/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serializeBuildManifest } from '../utils/fs'
|
|
1
2
|
import { copyFile, mkdir, rename, writeFile } from 'node:fs/promises'
|
|
2
3
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
3
4
|
import { createRequire } from 'node:module'
|
|
@@ -257,7 +258,7 @@ async function runBuild(
|
|
|
257
258
|
const partytownLib = log.step('partytown lib', () => copyPartytownLib(config))
|
|
258
259
|
partytownLib.catch(() => undefined)
|
|
259
260
|
|
|
260
|
-
let routes = await log.step('scan routes', () => scanRoutes(config.appPath))
|
|
261
|
+
let routes = await log.step('scan routes', () => scanRoutes(config.appPath, config.workspaceRoot))
|
|
261
262
|
log.log(`found ${routes.length} route${routes.length === 1 ? '' : 's'}`)
|
|
262
263
|
if (options.debugBuildPaths) {
|
|
263
264
|
routes = filterDebugBuildRoutes(config.root, routes, options.debugBuildPaths)
|
|
@@ -1055,7 +1056,7 @@ async function writeBuildManifest(outPath: string, manifest: BuildManifest) {
|
|
|
1055
1056
|
assertManifestServerArtifacts(outPath, manifest)
|
|
1056
1057
|
const file = path.join(outPath, 'manifest.json')
|
|
1057
1058
|
const temporary = path.join(outPath, `.manifest-${process.pid}.tmp`)
|
|
1058
|
-
await writeFile(temporary,
|
|
1059
|
+
await writeFile(temporary, serializeBuildManifest(manifest))
|
|
1059
1060
|
await rename(temporary, file)
|
|
1060
1061
|
}
|
|
1061
1062
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveManifest } from '../../utils/fs'
|
|
1
2
|
/**
|
|
2
3
|
* The production request pipeline. Split out of `start.ts` so the prebundled
|
|
3
4
|
* server entry parses only what listening needs: this module (renderer,
|
|
@@ -99,9 +100,16 @@ export async function createRequestHandler(
|
|
|
99
100
|
const manifestPath = path.join(config.outPath, 'manifest.json')
|
|
100
101
|
// `start` reads the manifest before binding the port (a missing build must
|
|
101
102
|
// still fail there, not on the first request) and hands it over.
|
|
102
|
-
const manifest =
|
|
103
|
-
options.manifest ?? (JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest)
|
|
104
|
-
|
|
103
|
+
const manifest = resolveManifest(
|
|
104
|
+
options.manifest ?? (JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest),
|
|
105
|
+
config.outPath,
|
|
106
|
+
config.root,
|
|
107
|
+
)
|
|
108
|
+
const persistManifest = async () => {
|
|
109
|
+
const stored = JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest
|
|
110
|
+
stored.staticFiles = manifest.staticFiles
|
|
111
|
+
await writeFile(manifestPath, `${JSON.stringify(stored, null, 2)}\n`)
|
|
112
|
+
}
|
|
105
113
|
// Compat action-registry arming + serverActions.bodySizeLimit resolution +
|
|
106
114
|
// fetch-cache install now run through the extension registry: the action
|
|
107
115
|
// dispatch interceptor rebuilds the registry from manifest.actions on the
|
package/src/cli/typegen.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import type { ResolvedConfig } from '../config'
|
|
5
5
|
import { loadConfig } from '../config'
|
|
6
|
-
import {
|
|
6
|
+
import { bootstrapCompatBoot, generateCompatRouteTypes } from '../compat-bootstrap'
|
|
7
|
+
import { ensureDir, includeTypes, toPosixPath, writeFileAtomic } from '../utils/fs'
|
|
7
8
|
import { scanRoutes } from '../routing/routes'
|
|
8
9
|
import type { RouteManifestEntry } from '../types'
|
|
9
10
|
|
|
@@ -16,8 +17,11 @@ export interface TypegenResult {
|
|
|
16
17
|
|
|
17
18
|
export async function typegenProject(root?: string): Promise<TypegenResult> {
|
|
18
19
|
const config = await loadConfig(root)
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
await bootstrapCompatBoot(config)
|
|
21
|
+
const routes = await scanRoutes(config.appPath, config.workspaceRoot)
|
|
22
|
+
const result = await writeTypegen(config, routes)
|
|
23
|
+
await generateCompatRouteTypes(config, routes)
|
|
24
|
+
return result
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export async function writeTypegen(
|
|
@@ -28,6 +32,7 @@ export async function writeTypegen(
|
|
|
28
32
|
const file = path.join(config.typesPath, 'pnext.gen.d.ts')
|
|
29
33
|
const source = typegenSource(routes)
|
|
30
34
|
await writeIfChanged(file, source)
|
|
35
|
+
await writeCoreEnv(config, file, allRoutes)
|
|
31
36
|
const aliases = await writeRouteAliases(config, routes)
|
|
32
37
|
const checks = await writeChecks(config, routes)
|
|
33
38
|
return { file, routes: routes.length, checks, aliases }
|
|
@@ -162,3 +167,60 @@ async function writeIfChanged(file: string, source: string) {
|
|
|
162
167
|
await ensureDir(path.dirname(file))
|
|
163
168
|
await writeFile(file, source)
|
|
164
169
|
}
|
|
170
|
+
|
|
171
|
+
async function writeCoreEnv(
|
|
172
|
+
config: ResolvedConfig,
|
|
173
|
+
typesFile: string,
|
|
174
|
+
routes: RouteManifestEntry[],
|
|
175
|
+
) {
|
|
176
|
+
if (config.compat?.next) return
|
|
177
|
+
const file = path.join(config.root, 'tsconfig.json')
|
|
178
|
+
const exists = existsSync(file)
|
|
179
|
+
if (!exists && !routes.some(route => route.sourceFiles.some(file => /\.[cm]?tsx?$/.test(file))))
|
|
180
|
+
return
|
|
181
|
+
const reference = toPosixPath(path.relative(config.root, typesFile))
|
|
182
|
+
await writeIfChanged(
|
|
183
|
+
path.join(config.root, 'pnext-env.d.ts'),
|
|
184
|
+
`/// <reference types="@wular/pnext" />\n/// <reference path="${reference}" />\n\n// This file is generated by PNext. Do not edit.\n`,
|
|
185
|
+
)
|
|
186
|
+
if (!exists) return
|
|
187
|
+
let source: string
|
|
188
|
+
let tsconfig: { include?: unknown }
|
|
189
|
+
try {
|
|
190
|
+
source = await readFile(file, 'utf8')
|
|
191
|
+
tsconfig = Bun.JSONC.parse(source) as typeof tsconfig
|
|
192
|
+
} catch {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
if (!tsconfig || !Array.isArray(tsconfig.include)) return
|
|
196
|
+
const typesGlob = `${toPosixPath(path.relative(config.root, config.typesPath))}/**/*.ts`
|
|
197
|
+
const added = includeTypes(tsconfig, ['pnext-env.d.ts', typesGlob], [])
|
|
198
|
+
if (added.length) await writeFileAtomic(file, updateTypeSelection(source, added))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function updateTypeSelection(source: string, added: string[]) {
|
|
202
|
+
// Token offsets preserve comments, whitespace and escaped strings outside the edited selection.
|
|
203
|
+
const tokens = [
|
|
204
|
+
...source.matchAll(/"(?:\\[\s\S]|[^"\\])*"|\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|[^\s]/g),
|
|
205
|
+
].filter(token => !token[0].startsWith('//') && !token[0].startsWith('/*'))
|
|
206
|
+
let depth = 0
|
|
207
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
208
|
+
const token = tokens[index]!
|
|
209
|
+
if (
|
|
210
|
+
depth === 1 &&
|
|
211
|
+
token[0].startsWith('"') &&
|
|
212
|
+
JSON.parse(token[0]) === 'include' &&
|
|
213
|
+
tokens[index + 1]?.[0] === ':'
|
|
214
|
+
) {
|
|
215
|
+
let end = index + 3
|
|
216
|
+
while (tokens[end]?.[0] !== ']') end++
|
|
217
|
+
const close = tokens[end]!
|
|
218
|
+
const previous = tokens[end - 1]![0]
|
|
219
|
+
const insertion = `${previous === '[' || previous === ',' ? '' : ','} ${added.map(value => JSON.stringify(value)).join(', ')}`
|
|
220
|
+
return source.slice(0, close.index) + insertion + source.slice(close.index)
|
|
221
|
+
}
|
|
222
|
+
if (token[0] === '[' || token[0] === '{') depth++
|
|
223
|
+
if (token[0] === ']' || token[0] === '}') depth--
|
|
224
|
+
}
|
|
225
|
+
return source
|
|
226
|
+
}
|
package/src/client/build.ts
CHANGED
|
@@ -1532,7 +1532,8 @@ function createClientSourcePipeline(config: ResolvedConfig, dev = false, routeId
|
|
|
1532
1532
|
contents = rewriteDeferredDynamicImports(
|
|
1533
1533
|
contents,
|
|
1534
1534
|
resolved,
|
|
1535
|
-
specifier =>
|
|
1535
|
+
specifier =>
|
|
1536
|
+
resolveImport(rootFromFile(resolved), resolved, specifier, config.workspaceRoot),
|
|
1536
1537
|
target => {
|
|
1537
1538
|
const id = clientReferenceId(target.file, target.exportName)
|
|
1538
1539
|
const ref = { ...target, routeId }
|
|
@@ -2089,7 +2090,9 @@ export async function clientCacheKeyParts(
|
|
|
2089
2090
|
): Promise<ClientCacheKeyParts> {
|
|
2090
2091
|
const staticDigest = staticHash ?? clientCacheStaticHash(route, nextCompat, config)
|
|
2091
2092
|
const sources = await Promise.all(
|
|
2092
|
-
(await clientSourceFiles(route)).map(file =>
|
|
2093
|
+
(await clientSourceFiles(route, config?.workspaceRoot)).map(file =>
|
|
2094
|
+
readClientCacheSource(file),
|
|
2095
|
+
),
|
|
2093
2096
|
)
|
|
2094
2097
|
return { key: clientCacheKeyFrom(staticDigest, sources), staticHash: staticDigest, sources }
|
|
2095
2098
|
}
|
|
@@ -2102,14 +2105,14 @@ export async function clientCacheKey(
|
|
|
2102
2105
|
return (await clientCacheKeyParts(route, nextCompat, config)).key
|
|
2103
2106
|
}
|
|
2104
2107
|
|
|
2105
|
-
async function clientSourceFiles(route: RouteManifestEntry) {
|
|
2108
|
+
async function clientSourceFiles(route: RouteManifestEntry, workspaceRoot?: string) {
|
|
2106
2109
|
const entryFiles = [
|
|
2107
2110
|
...(route.client ? [route.file] : []),
|
|
2108
2111
|
...route.clientReferences.map(reference => reference.file),
|
|
2109
2112
|
]
|
|
2110
2113
|
const files = new Set<string>()
|
|
2111
2114
|
|
|
2112
|
-
await Promise.all(entryFiles.map(file => collectClientSourceFile(file, files)))
|
|
2115
|
+
await Promise.all(entryFiles.map(file => collectClientSourceFile(file, files, workspaceRoot)))
|
|
2113
2116
|
|
|
2114
2117
|
return [...files].sort()
|
|
2115
2118
|
}
|
|
@@ -2117,7 +2120,7 @@ async function clientSourceFiles(route: RouteManifestEntry) {
|
|
|
2117
2120
|
// The walk fans out: every file joins `files` before its own read starts, so
|
|
2118
2121
|
// concurrent branches still visit each file once. Serially this was the whole
|
|
2119
2122
|
// cost of a route's client cache key (~1.8 s on a 300-module route).
|
|
2120
|
-
async function collectClientSourceFile(file: string, files: Set<string
|
|
2123
|
+
async function collectClientSourceFile(file: string, files: Set<string>, workspaceRoot?: string) {
|
|
2121
2124
|
if (files.has(file) || !existsSync(file)) return
|
|
2122
2125
|
files.add(file)
|
|
2123
2126
|
if (isAssetLike(file)) return
|
|
@@ -2125,8 +2128,8 @@ async function collectClientSourceFile(file: string, files: Set<string>) {
|
|
|
2125
2128
|
const source = rewriteLiteralDynamicCalls(await readText(file), file)
|
|
2126
2129
|
await Promise.all(
|
|
2127
2130
|
[...localImports(file, source)].map(specifier => {
|
|
2128
|
-
const resolved = resolveImport(rootFromFile(file), file, specifier)
|
|
2129
|
-
return resolved ? collectClientSourceFile(resolved, files) : Promise.resolve()
|
|
2131
|
+
const resolved = resolveImport(rootFromFile(file), file, specifier, workspaceRoot)
|
|
2132
|
+
return resolved ? collectClientSourceFile(resolved, files, workspaceRoot) : Promise.resolve()
|
|
2130
2133
|
}),
|
|
2131
2134
|
)
|
|
2132
2135
|
}
|
package/src/client/entry.ts
CHANGED
|
@@ -104,10 +104,23 @@ function serializeModulePath() {
|
|
|
104
104
|
// Re-provide the per-island layout-segment snapshot (stamped by the renderer as
|
|
105
105
|
// `data-pnext-layout-segments`) around a hydrated island so compat's
|
|
106
106
|
// useSelectedLayoutSegment(s) recomputes live from the layout depth. Emitted
|
|
107
|
-
// only under next compat (the hooks are compat-only);
|
|
107
|
+
// only under next compat (the hooks are compat-only); core keeps the children guard.
|
|
108
108
|
function layoutSegmentHelperSource(nextCompat?: boolean) {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// useId's mask (preact's _mask), seeded by data-pnext-id and kept on the instance (__c) across re-adoption.
|
|
110
|
+
const seed = `
|
|
111
|
+
function pnextIslandMask(vnode, root) {
|
|
112
|
+
const mask = root.__pnextMask ??= [root.getAttribute('data-pnext-id'), 0];
|
|
113
|
+
Object.defineProperty(vnode, '__m', { enumerable: true, get() { return (this.__c || this).__pnextMask ??= mask; } });
|
|
114
|
+
}`
|
|
115
|
+
if (!nextCompat)
|
|
116
|
+
return `${seed}
|
|
117
|
+
function pnextIslandVNode(h, Component, props, children, root) {
|
|
118
|
+
const vnode = children !== undefined ? h(Component, props, children) : h(Component, props);
|
|
119
|
+
pnextIslandMask(vnode, root);
|
|
120
|
+
return vnode;
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
return `${seed}
|
|
111
124
|
import { LayoutSegmentContext as __PNextLayoutSegmentContext, RouteParamsContext as __PNextRouteParamsContext } from ${JSON.stringify(islandContextModulePath())};
|
|
112
125
|
function pnextIslandReadJson(root, attr) {
|
|
113
126
|
const raw = root && root.getAttribute ? root.getAttribute(attr) : null;
|
|
@@ -118,6 +131,7 @@ function pnextIslandVNode(h, Component, props, children, root) {
|
|
|
118
131
|
// An explicit undefined children argument clobbers props.children in
|
|
119
132
|
// preact's createElement; plain children travel inside the parsed props.
|
|
120
133
|
let vnode = children !== undefined ? h(Component, props, children) : h(Component, props);
|
|
134
|
+
pnextIslandMask(vnode, root);
|
|
121
135
|
const segments = pnextIslandReadJson(root, 'data-pnext-layout-segments');
|
|
122
136
|
if (segments) vnode = h(__PNextLayoutSegmentContext.Provider, { value: segments }, vnode);
|
|
123
137
|
const params = pnextIslandReadJson(root, 'data-pnext-params');
|
|
@@ -132,11 +146,8 @@ function islandVNodeExpr(
|
|
|
132
146
|
propsExpr: string,
|
|
133
147
|
childrenExpr: string,
|
|
134
148
|
rootExpr: string,
|
|
135
|
-
nextCompat?: boolean,
|
|
136
149
|
) {
|
|
137
|
-
return
|
|
138
|
-
? `pnextIslandVNode(h, ${componentExpr}, ${propsExpr}, ${childrenExpr}, ${rootExpr})`
|
|
139
|
-
: `h(${componentExpr}, ${propsExpr}, ${childrenExpr})`
|
|
150
|
+
return `pnextIslandVNode(h, ${componentExpr}, ${propsExpr}, ${childrenExpr}, ${rootExpr})`
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
/**
|
|
@@ -1136,11 +1147,10 @@ function observeVisibleIsland(root, island) {
|
|
|
1136
1147
|
}
|
|
1137
1148
|
|
|
1138
1149
|
function visibleTarget(root) {
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
return root.parentElement ?? root;
|
|
1150
|
+
// IntersectionObserver never fires for a display:contents target: observe the nearest box.
|
|
1151
|
+
let target = root;
|
|
1152
|
+
while (target.parentElement && getComputedStyle(target).display === 'contents') target = target.parentElement;
|
|
1153
|
+
return target;
|
|
1144
1154
|
}
|
|
1145
1155
|
|
|
1146
1156
|
${mountOnceSource()}
|
|
@@ -1159,7 +1169,8 @@ async function mountIslandTree(root, island) {
|
|
|
1159
1169
|
]);
|
|
1160
1170
|
${facts.suspense !== false ? ' islandBoundary = Suspense;\n' : ''} const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
1161
1171
|
const source = preservedSource(root, render);
|
|
1162
|
-
|
|
1172
|
+
if (!source) return adoptPreserved(render, root);
|
|
1173
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id), root);
|
|
1163
1174
|
const wrapped = ${facts.suspense !== false ? 'h(Suspense, { fallback: null }, pnextClientBoundary(h, vnode))' : 'pnextClientBoundary(h, vnode)'};
|
|
1164
1175
|
if (source !== root) adoptPreserved(render, root, wrapped);
|
|
1165
1176
|
else mount(hydrate, render, root, wrapped);
|
|
@@ -1172,7 +1183,8 @@ async function mountIslandTree(root, island) {
|
|
|
1172
1183
|
const [{ h, hydrate, render }, Component] = await Promise.all([import('preact'), islandComponent(island)]);
|
|
1173
1184
|
const rawProps = root.getAttribute('data-pnext-props') ?? '{}';
|
|
1174
1185
|
const source = preservedSource(root, render);
|
|
1175
|
-
|
|
1186
|
+
if (!source) return adoptPreserved(render, root);
|
|
1187
|
+
const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id), root);
|
|
1176
1188
|
if (source !== root) adoptPreserved(render, root, vnode);
|
|
1177
1189
|
else mount(hydrate, render, root, vnode);
|
|
1178
1190
|
}`
|
|
@@ -1200,16 +1212,18 @@ function preservedSource(root, render) {
|
|
|
1200
1212
|
const incoming = root.__pnextIncoming;
|
|
1201
1213
|
root.__pnextIncoming = undefined;
|
|
1202
1214
|
if (!incoming) return root;
|
|
1203
|
-
|
|
1215
|
+
// A kept layout's island stashed as its own incoming has no new render: keep its tree.
|
|
1216
|
+
if (root.__pnextLive === render) return incoming === root ? null : incoming;
|
|
1204
1217
|
root.replaceChildren(...incoming.childNodes);
|
|
1205
1218
|
root.__pnextLive = undefined;
|
|
1219
|
+
root.__pnextMask = [incoming.getAttribute('data-pnext-id'), 0];
|
|
1206
1220
|
return root;
|
|
1207
1221
|
}
|
|
1208
1222
|
|
|
1209
1223
|
// Re-render the preserved root in place so component state survives while the
|
|
1210
1224
|
// routed content under the island updates.
|
|
1211
1225
|
function adoptPreserved(render, root, vnode) {
|
|
1212
|
-
render(vnode, root);
|
|
1226
|
+
if (vnode) render(vnode, root);
|
|
1213
1227
|
pnextRender = render;
|
|
1214
1228
|
pnextMountedRoots.add(root);
|
|
1215
1229
|
}
|
|
@@ -1230,8 +1244,10 @@ function mount(hydrate, render, root, vnode) {
|
|
|
1230
1244
|
// Adopted DOM children win (element children); plain children travel inside
|
|
1231
1245
|
// the serialized props instead, and an explicit undefined third argument would
|
|
1232
1246
|
// clobber props.children in preact's createElement — so only pass it when set.
|
|
1233
|
-
function islandVNode(h, Component, props, adopted) {
|
|
1234
|
-
|
|
1247
|
+
function islandVNode(h, Component, props, adopted, root) {
|
|
1248
|
+
const vnode = adopted !== undefined ? h(Component, props, adopted) : h(Component, props);
|
|
1249
|
+
pnextIslandMask(vnode, root);
|
|
1250
|
+
return vnode;
|
|
1235
1251
|
}
|
|
1236
1252
|
|
|
1237
1253
|
async function staticChildren(h, root, id) {
|
|
@@ -1278,7 +1294,7 @@ async function domNode(h, node) {
|
|
|
1278
1294
|
// A nested island must match its previous incarnation by IDENTITY, or the shift remounts
|
|
1279
1295
|
// it and resets its state - Next never remounts layout components on navigation.
|
|
1280
1296
|
if (props.key === undefined) props.key = 'pnext-island:' + id;
|
|
1281
|
-
const vnode = islandVNode(h, Component, props, await staticChildren(h, element, island.id));
|
|
1297
|
+
const vnode = islandVNode(h, Component, props, await staticChildren(h, element, island.id), element);
|
|
1282
1298
|
return ${nextCompat ? 'islandBoundary ? h(islandBoundary, { fallback: null }, pnextClientBoundary(h, vnode)) : pnextClientBoundary(h, vnode)' : 'vnode'};
|
|
1283
1299
|
}
|
|
1284
1300
|
|
|
@@ -1531,7 +1547,7 @@ function diagnoseSkippedIslands() {
|
|
|
1531
1547
|
// claim pass adopted is no longer in the document. Everything else is a host
|
|
1532
1548
|
// this route can never hydrate.
|
|
1533
1549
|
if (island?.options?.load === 'visible') continue;
|
|
1534
|
-
if (root.closest('pnext-hole-capture')) continue;
|
|
1550
|
+
if (root.closest('pnext-hole-capture, [data-pnext-stream]')) continue;
|
|
1535
1551
|
if (parent && !parent.__pnextLive) continue;
|
|
1536
1552
|
const reason = island
|
|
1537
1553
|
? 'was materialized after its parent island mounted, outside any streamed hole this route can re-adopt'
|
|
@@ -1562,6 +1578,8 @@ for (const island of islands) {
|
|
|
1562
1578
|
}
|
|
1563
1579
|
for (const island of islands) {
|
|
1564
1580
|
for (const root of document.querySelectorAll(\`[data-pnext-client="\${island.id}"]\`)) {
|
|
1581
|
+
// React hydrates a streamed boundary only once revealed; its promotion re-runs this scan.
|
|
1582
|
+
if (root.closest('[data-pnext-stream]')) continue;
|
|
1565
1583
|
const parent = nestedIslandParent(root);
|
|
1566
1584
|
if (parent) {
|
|
1567
1585
|
pending.push(claimNestedIslandRoot(root, parent));
|
|
@@ -1625,8 +1643,11 @@ async function mountIslandTree(root, island) {
|
|
|
1625
1643
|
// Preserved across a soft navigation: re-render in place with the incoming
|
|
1626
1644
|
// document's props/children so component state survives while the routed
|
|
1627
1645
|
// content under the island updates.
|
|
1628
|
-
|
|
1629
|
-
|
|
1646
|
+
// A kept layout's island (its own incoming) keeps its tree.
|
|
1647
|
+
if (incoming !== root) {
|
|
1648
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root')};
|
|
1649
|
+
render(${wrapInBoundary('vnode', nextCompat)}, root);
|
|
1650
|
+
}
|
|
1630
1651
|
pnextMountedRoots.add(root);
|
|
1631
1652
|
return;
|
|
1632
1653
|
}
|
|
@@ -1635,8 +1656,9 @@ async function mountIslandTree(root, island) {
|
|
|
1635
1656
|
// instance can't diff its tree — restore the SSR children and mount fresh.
|
|
1636
1657
|
root.replaceChildren(...incoming.childNodes);
|
|
1637
1658
|
root.__pnextLive = undefined;
|
|
1659
|
+
root.__pnextMask = [incoming.getAttribute('data-pnext-id'), 0];
|
|
1638
1660
|
}
|
|
1639
|
-
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root'
|
|
1661
|
+
const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, root, domChildren, h)', 'await staticChildren(root, island.id)', 'root')};
|
|
1640
1662
|
mount(root, ${wrapInBoundary('vnode', nextCompat)});
|
|
1641
1663
|
}
|
|
1642
1664
|
|
|
@@ -1713,7 +1735,7 @@ async function domNode(node) {
|
|
|
1713
1735
|
: ''
|
|
1714
1736
|
}
|
|
1715
1737
|
const Component = await islandComponent(island);
|
|
1716
|
-
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element'
|
|
1738
|
+
return ${wrapInBoundary(islandVNodeExpr('Component', 'await islandProps(rawProps, element, domChildren, h)', 'children', 'element'), nextCompat)};
|
|
1717
1739
|
}
|
|
1718
1740
|
|
|
1719
1741
|
if (Page && element.id === 'pnext-page') {
|
|
@@ -1755,11 +1777,10 @@ ${holeSeamSource()}${
|
|
|
1755
1777
|
visibleIslands
|
|
1756
1778
|
? `
|
|
1757
1779
|
function visibleTarget(root) {
|
|
1758
|
-
//
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
return root.parentElement ?? root;
|
|
1780
|
+
// IntersectionObserver never fires for a display:contents target: observe the nearest box.
|
|
1781
|
+
let target = root;
|
|
1782
|
+
while (target.parentElement && getComputedStyle(target).display === 'contents') target = target.parentElement;
|
|
1783
|
+
return target;
|
|
1763
1784
|
}
|
|
1764
1785
|
`
|
|
1765
1786
|
: ''
|
|
@@ -86,7 +86,7 @@ export async function clientReferenceExportNames(
|
|
|
86
86
|
// excluded, per ES module star semantics). `export * as ns from` exports
|
|
87
87
|
// only `ns`, which the own-module scan already lists.
|
|
88
88
|
for (const specifier of moduleExportStars(text, file)) {
|
|
89
|
-
const resolved = resolveImport(config.root, file, specifier)
|
|
89
|
+
const resolved = resolveImport(config.root, file, specifier, config.workspaceRoot)
|
|
90
90
|
if (!resolved || !isInside(config.workspaceRoot, resolved)) continue
|
|
91
91
|
for (const name of await clientReferenceExportNames(config, resolved, undefined, visited)) {
|
|
92
92
|
if (name !== 'default') names.add(name)
|