@wular/pnext 0.1.3 → 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.
Files changed (44) hide show
  1. package/config/ts/react.json +2 -1
  2. package/package.json +1 -1
  3. package/reference/navigation.md +3 -1
  4. package/src/api/client-navigation.ts +53 -4
  5. package/src/api/link.tsx +6 -7
  6. package/src/cli/adapters/vercel-warm.ts +6 -1
  7. package/src/cli/adapters/vercel.ts +1 -1
  8. package/src/cli/build/cache.ts +8 -3
  9. package/src/cli/build.ts +3 -2
  10. package/src/cli/serve/pipeline.ts +11 -3
  11. package/src/cli/typegen.ts +65 -3
  12. package/src/client/build.ts +10 -7
  13. package/src/client/entry.ts +50 -29
  14. package/src/client/reference-stub.ts +1 -1
  15. package/src/client/router/hub.ts +17 -9
  16. package/src/client/router/page-slot.ts +58 -0
  17. package/src/client/router/runtime.ts +387 -388
  18. package/src/compat/bundler/symlink-imports.ts +1 -1
  19. package/src/compat/client/errors/bare-boundary.ts +1 -1
  20. package/src/compat/client/errors/error-boundary.ts +1 -1
  21. package/src/compat/client/errors/global-error.ts +40 -79
  22. package/src/compat/client/errors/install.ts +1 -1
  23. package/src/compat/client/errors/lazy.ts +7 -0
  24. package/src/compat/client/errors/primitive-throw.ts +36 -53
  25. package/src/compat/client/segment-cache-policy.ts +5 -22
  26. package/src/compat/client/segment-cache.ts +7 -26
  27. package/src/compat/css/modules.ts +1 -7
  28. package/src/compat/css/sass.ts +1 -7
  29. package/src/compat/react/parity.ts +1 -1
  30. package/src/compat/register/bundler.ts +9 -1
  31. package/src/compat/tsconfig-defaults.ts +4 -7
  32. package/src/compat-bootstrap.ts +15 -0
  33. package/src/css/build.ts +18 -15
  34. package/src/dev/server.ts +2 -2
  35. package/src/index.ts +2 -0
  36. package/src/render/renderer.ts +209 -24
  37. package/src/render/resource-hints.ts +4 -1
  38. package/src/resolve/imports.ts +13 -8
  39. package/src/routing/routes.ts +32 -18
  40. package/src/runtime/loader.ts +29 -10
  41. package/src/runtime/modules.ts +28 -7
  42. package/src/runtime/vendor-build.ts +2 -2
  43. package/src/types.ts +2 -0
  44. package/src/utils/fs.ts +122 -0
@@ -4,6 +4,7 @@
4
4
  "extends": "./base.json",
5
5
  "compilerOptions": {
6
6
  "lib": ["DOM", "DOM.Iterable", "ESNext"],
7
- "jsx": "react-jsx"
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "preact"
8
9
  }
9
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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()` | Currently always `{ pending: false }`. |
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
- return { pending: false }
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
- search: '',
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') return {}
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 = MouseEvent<HTMLAnchorElement>
11
+ type LinkMouseEvent = JSX.TargetedMouseEvent<HTMLAnchorElement>
13
12
 
14
- type BaseLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'onClick'> & {
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?: ReactNode
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>): ReactNode {
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 as ComponentChildren,
76
- ) as unknown as ReactNode
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 = JSON.parse(await readFile(manifestFile, 'utf8')) as BuildManifest
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) &&
@@ -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 = JSON.parse(
77
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
78
- ) as BuildManifest
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, `${JSON.stringify(manifest, null, 2)}\n`)
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
- const persistManifest = () => writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
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
@@ -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 { ensureDir, toPosixPath } from '../utils/fs'
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
- const routes = await scanRoutes(config.appPath)
20
- return writeTypegen(config, routes)
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
+ }
@@ -1532,7 +1532,8 @@ function createClientSourcePipeline(config: ResolvedConfig, dev = false, routeId
1532
1532
  contents = rewriteDeferredDynamicImports(
1533
1533
  contents,
1534
1534
  resolved,
1535
- specifier => resolveImport(rootFromFile(resolved), resolved, 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 => readClientCacheSource(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
  }
@@ -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); a no-op wrapper otherwise.
107
+ // only under next compat (the hooks are compat-only); core keeps the children guard.
108
108
  function layoutSegmentHelperSource(nextCompat?: boolean) {
109
- if (!nextCompat) return ''
110
- return `
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 nextCompat
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
- // The host's OWN box: hostMeasureSource delegates the patched one to the
1140
- // parent, and IntersectionObserver never fires for a display:contents target.
1141
- const rect = Element.prototype.getBoundingClientRect.call(root);
1142
- if (rect.width || rect.height) return root;
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
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
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
- const vnode = islandVNode(h, Component, await islandProps(rawProps, source, node => domChildren(h, node), h), await staticChildren(h, source, island.id));
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
- if (root.__pnextLive === render) return incoming;
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
- return adopted !== undefined ? h(Component, props, adopted) : h(Component, props);
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
- const vnode = ${islandVNodeExpr('Component', 'await islandProps(rawProps, incoming, domChildren, h)', 'await staticChildren(incoming, island.id)', 'root', nextCompat)};
1629
- render(${wrapInBoundary('vnode', nextCompat)}, root);
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', nextCompat)};
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', nextCompat), nextCompat)};
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
- // The host's OWN box: hostMeasureSource delegates the patched one to the
1759
- // parent, and IntersectionObserver never fires for a display:contents target.
1760
- const rect = Element.prototype.getBoundingClientRect.call(root);
1761
- if (rect.width || rect.height) return root;
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)