@wular/pnext 0.0.6 → 0.0.7

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.
@@ -1,39 +1,46 @@
1
1
  # Routing
2
2
 
3
- ## Files
3
+ Routes come from the file tree under `app/`. Directories make the URL path, and a `page` file inside one renders it as its default export.
4
4
 
5
- - `app/**/page` defines a UI route.
6
- - `app/**/layout` exports metadata and can wrap descendant pages.
7
- - `app/**/route` defines HTTP handlers.
8
- - `proxy` or `middleware` runs before route matching.
9
- - `app/**/loading` defines a Suspense fallback for the segment.
10
- - `app/**/error` defines fallback UI for server render failures in the segment.
11
- - `app/**/not-found` defines fallback UI.
5
+ ## Path patterns
12
6
 
13
- Convention files are matched across `.tsx`, `.ts`, `.jsx`, `.js`, and `.mjs`.
14
- `.tsx`/`.ts` are tried first, so an all-`.tsx` app resolves on the first
15
- candidate. Route handlers (`route`) match `.ts`, `.tsx`, `.js`, `.mjs`.
7
+ | Directory | Serves |
8
+ | -------------------------- | --------------------------------------- |
9
+ | `app/about/` | `/about` |
10
+ | `app/users/[id]/` | `/users/:id` |
11
+ | `app/docs/[...slug]/` | `/docs/*` |
12
+ | `app/docs/[[...slug]]/` | `/docs` and `/docs/*` |
13
+ | `app/(marketing)/pricing/` | `/pricing`, the group adds no segment |
14
+ | `app/dashboard/@team/` | a slot of `/dashboard`, no path segment |
16
15
 
17
- ## Segments
16
+ ## Convention files
18
17
 
19
- - `app/about/page.tsx` -> `/about`
20
- - `app/users/[id]/page.tsx` -> `/users/:id`
21
- - `app/docs/[...slug]/page.tsx` -> `/docs/*`
22
- - `app/docs/[[...slug]]/page.tsx` -> matches `/docs` AND `/docs/*` (the base path is included)
23
- - `app/(marketing)/pricing/page.tsx` -> `/pricing`
24
- - `app/dashboard/@team/page.tsx` -> a `@slot` directory adds no path segment; it is rendered as a slot of `/dashboard`
18
+ | File | What it does |
19
+ | -------------- | --------------------------------------------------------- |
20
+ | `page` | Renders a UI route. |
21
+ | `layout` | Exports metadata and can wrap descendant pages. |
22
+ | `route` | Defines HTTP handlers. |
23
+ | `loading` | Suspense fallback for the segment. |
24
+ | `error` | Fallback UI when server rendering throws in the segment. |
25
+ | `not-found` | Fallback UI for `notFound()` and unmatched URLs. |
26
+ | `template` | Wraps children like a layout, but re-renders per request. |
27
+ | `default` | Fallback for a parallel slot with no page match. |
28
+ | `global-error` | Replaces the whole document for an uncaught root error. |
25
29
 
26
- ### Intercepting markers
30
+ Convention files are matched across `.tsx`, `.ts`, `.jsx`, `.js`, and `.mjs`, with the TypeScript extensions tried first. Route handlers are the exception: they do not match `.jsx`. A proxy file lives at the project root rather than in a segment, and is covered under [Proxy](#proxy).
27
31
 
28
- A marker prefixes a segment to intercept a route from another level. The marker
29
- is stripped from the resulting path. See [Intercepting routes](#intercepting-routes).
32
+ Behavior worth knowing:
30
33
 
31
- - `(.)segment` -> same level.
32
- - `(..)segment` -> one level up.
33
- - `(..)(..)segment` -> two levels up.
34
- - `(...)segment` -> from the app root.
34
+ - `loading`: static builds wait for the final HTML, while dev and server responses can stream the fallback first.
35
+ - `error`: receives `error`, `reset`, and `unstable_retry`. Both callbacks refresh the current route in `compat.next` apps.
36
+ - `not-found`: `pnext build` also writes `404.html` when the app root has one.
37
+ - `global-error`: renders its own `<html>` and `<body>`, so pnext serves that markup instead of the normal document shell. A nearer error boundary wins, and a `"use client"` directive opts the file out.
38
+ - `template`: nesting order is layout, then template, then children. A segment with no layout still applies its template.
39
+ - `forbidden` and `unauthorized`: experimental `compat.next` boundaries that render 403 and 401 responses. Without compat, the thrown errors take the normal error path. The `forbidden()` and `unauthorized()` functions are core exports either way. See [Navigation](./navigation.md#auth-interrupts).
35
40
 
36
- ## Page Props
41
+ To leave a route, use `redirect()` or `notFound()`. See [Navigation](./navigation.md#redirects-and-not-found).
42
+
43
+ ## Page props
37
44
 
38
45
  Pages receive explicit props:
39
46
 
@@ -45,67 +52,71 @@ type PageProps<P = Record<string, string | string[]>> = {
45
52
  }
46
53
  ```
47
54
 
48
- Request data is read from `props.request`.
55
+ Read request data from the request prop. Typed per-route versions of these props are generated for you; see [Type Safety](./typegen.md).
49
56
 
50
- ## Route Handlers
57
+ ## Route handlers
51
58
 
52
- Route handlers use the Next-compatible `(request, context)` shape:
59
+ A `route` file exports one function per HTTP method. The request extends the standard `Request` with convenience access for cookies and `nextUrl`, and the context carries the route params.
53
60
 
54
61
  ```ts
55
- import type { RouteContext } from '#gen/app/api/users/[id]/route'
56
- import type { NextRequest } from '@wular/pnext/server'
57
-
58
- export async function GET(request: NextRequest, { params }: RouteContext) {
62
+ export async function GET(
63
+ request: NextRequest,
64
+ { params }: RouteContext,
65
+ ) {
59
66
  const { id } = await params
60
- return Response.json({
61
- id,
62
- q: request.nextUrl.searchParams.get('q'),
63
- })
67
+ return Response.json({ id })
64
68
  }
65
69
  ```
66
70
 
67
- `request` is a `NextRequest` compatible object. It extends the standard `Request` with convenience access for cookies and `nextUrl`.
68
-
69
- If `OPTIONS` is not exported, pnext returns an automatic `OPTIONS` response with an `Allow` header. Unsupported methods return `405 Method Not Allowed` with the same `Allow` header.
71
+ If a handler does not export `OPTIONS`, pnext answers with an automatic response carrying an `Allow` header. Unsupported methods get a 405 with that same header.
70
72
 
71
- Static `GET` route handlers are written as files during `pnext build`. Dynamic route handlers can export `params()` to write selected param outputs at build time.
73
+ Static `GET` handlers are written as files during the build. A dynamic handler can export `params()` to write selected param outputs at build time.
72
74
 
73
- `generateStaticParams()` is supported in Next compat mode.
75
+ ## Static and dynamic routes
74
76
 
75
- ## Route Rendering
77
+ pnext infers rendering from what a route uses:
76
78
 
77
- pnext infers route rendering separately from hydration:
79
+ - Static: no dynamic params and no request prop usage.
80
+ - Dynamic: request prop usage or dynamic route segments.
78
81
 
79
- - `static`: no dynamic params and no request prop usage.
80
- - `dynamic`: request prop usage or dynamic route segments.
82
+ Segment config overrides the inference. Hydration is inferred separately, from `"use client"`.
81
83
 
82
- Route segment config overrides the inference. Hydration is controlled by `"use client"`.
84
+ ## Route segment config
83
85
 
84
- ## Route Segment Config
85
-
86
- Pages, layouts, and route handlers can export segment config. Values must be literals — they are read from the source, not evaluated.
86
+ Pages, layouts, and route handlers can export segment config. Values must be literals, because they are read from the source rather than evaluated.
87
87
 
88
88
  ```ts
89
89
  export const dynamic = 'force-dynamic'
90
90
  export const maxDuration = 60
91
91
  ```
92
92
 
93
- Recognized: `dynamic` (`auto` | `force-dynamic` | `force-static` | `error`), `revalidate` (a non-negative number or `false`), `fetchCache`, `dynamicParams`, `runtime` (`edge` | `nodejs`), `prefetch`, and `maxDuration`.
93
+ - `dynamic`: `auto`, `force-dynamic`, `force-static`, or `error`.
94
+ - `revalidate`: a non-negative number, or `false`.
95
+ - `dynamicParams`: `false` restricts the route to its generated params.
96
+ - `runtime`: `edge` or `nodejs`.
97
+ - `maxDuration`: seconds, copied into the generated Vercel Node function metadata.
98
+ - `fetchCache`: `auto`, `default-cache`, `only-cache`, `force-cache`, `force-no-store`, `default-no-store`, or `only-no-store`.
99
+ - `prefetch`: `allow-runtime`, `partial`, or `unstable_eager`.
100
+ - `unstable_instant`: experimental, described below.
101
+
102
+ Config merges across the route's own file and its layout chain, leaf-first, so the nearest declaration wins. Two values behave differently: `force-dynamic` on any segment forces the whole route, and the lowest declared `revalidate` wins.
94
103
 
95
- Config merges across the route's own file and its layout chain, leaf-first: the nearest declaration wins, except `force-dynamic` (any segment forces the whole route) and `revalidate` (the lowest declared number wins).
104
+ Setting `dynamicParams` to false applies to the dynamic segment declared by that file. When every dynamic segment has static params, requests must match a generated param tuple. Otherwise pnext drops the restriction and renders the route on demand. A page-level static params export makes this check apply to the complete param tuple.
96
105
 
97
- When building with `pnext build --adapter vercel`, `maxDuration` is copied to the generated Vercel Node function metadata.
106
+ `unstable_instant` is experimental. Set it to `true` or to an object of samples to opt in, and a leaf `false` overrides a layout opt-in. A full prefetch of such a route uses sampled request data and leaves `connection()`-gated content out of that prefetch render.
98
107
 
99
- ## File Conventions
108
+ `experimental_ppr` is also experimental. For a request-using page without dynamic route segments, it records the page as a partial-prerendering candidate. See [Rendering](./rendering.md#experimental-partial-prerendering-and-cachecomponents).
100
109
 
101
- `proxy.ts` and `middleware.ts` run before static files, pages, and route handlers. Use `proxy.ts` for new code. `middleware.ts` is supported for compatibility. If both exist, `proxy.ts` wins.
110
+ `preferredRegion` is a `compat.next`-only export. It accepts a string or string array and is written to compat deployment metadata.
111
+
112
+ ## Proxy
113
+
114
+ A `proxy` file at the project root runs before static files, pages, and route handlers.
102
115
 
103
116
  ```ts
104
117
  import { NextResponse, type NextRequest } from '@wular/pnext/server'
105
118
 
106
- export const config = {
107
- matcher: ['/admin/:path*'],
108
- }
119
+ export const config = { matcher: ['/admin/:path*'] }
109
120
 
110
121
  export function proxy(request: NextRequest) {
111
122
  if (!request.cookies.has('session')) {
@@ -115,53 +126,32 @@ export function proxy(request: NextRequest) {
115
126
  }
116
127
  ```
117
128
 
118
- Supported responses:
119
-
120
- - `NextResponse.next()` continues to the matched static file, page, or route handler.
121
- - `NextResponse.redirect(url)` returns a redirect response.
122
- - `NextResponse.rewrite(url)` serves another path without changing the browser URL.
123
- - `new Response(...)` returns a custom response directly.
124
-
125
- `config.matcher` takes a string, a string array, or objects with `source` plus `has`/`missing` conditions (`header`, `query`, `cookie`, `host`). Patterns support exact paths, `:param`, `:path*`, and regex. With no `matcher`, every request runs through the proxy.
129
+ Return one of:
126
130
 
127
- `loading.tsx` is used as the fallback for automatically wrapped server Suspense boundaries. Static builds wait for final HTML; dev and server responses can stream the fallback first.
131
+ - `NextResponse.next()` to continue to the matched static file, page, or route handler.
132
+ - `NextResponse.redirect(url)` for a redirect response.
133
+ - `NextResponse.rewrite(url)` to serve another path without changing the browser URL.
134
+ - `new Response(...)` for a custom response.
128
135
 
129
- `not-found.tsx` is rendered when a route calls `notFound()` or when no route matches. `pnext build` also writes `404.html` when root `app/not-found.tsx` exists.
136
+ The matcher accepts a string, an array of strings, or objects with a source pattern plus `has` and `missing` conditions on header, query, cookie, and host. Patterns support exact paths, `:param`, `:path*`, and regex. Without a matcher, every request runs through the proxy.
130
137
 
131
- `global-error.tsx` at the app root replaces the entire document when an unhandled error reaches the root uncaught. It renders its own `<html>` and `<body>`, so pnext skips the normal document shell and serves its markup directly. It only takes over when no nearer `error.tsx` boundary catches first, and a `"use client"` directive opts the file out.
138
+ A `middleware` file works the same way and is supported for compatibility. Use a proxy file for new code, and note that it wins if both exist.
132
139
 
133
- `redirect()` and `permanentRedirect()` stop rendering and return redirect responses. They can be called from pages, server components, route handlers, and server helpers used during rendering.
134
- Validate redirect targets that come from user input.
140
+ ## Static params
135
141
 
136
- `error.tsx` is rendered when server rendering throws. It receives `error`, `reset`, and `unstable_retry`; `reset()`/`unstable_retry()` refresh the current route in `compat.next` apps.
142
+ Export `params()` from a dynamic route to choose which params `pnext build` writes as static HTML. See the typed example in [Type Safety](./typegen.md). Under `compat.next`, `generateStaticParams()` fills the same role when a module has no `params()` export.
137
143
 
138
- `template.tsx` wraps the segment's children like a layout, but the segment re-renders per request where a layout persists. Nesting order is layout > template > children. A segment without a layout still applies its template.
144
+ ## Parallel routes
139
145
 
140
- `default.tsx` is the fallback rendered for a parallel `@slot` that has no page match.
146
+ A slot directory such as `@team` contributes no path segment. Its content is rendered and passed to the owning segment's layout as a prop named after the slot, alongside `children`. A slot uses its `page` when present, then its `default` fallback, and otherwise renders nothing. A slot's `loading` file wraps its content in a Suspense boundary.
141
147
 
142
- `forbidden.tsx` and `unauthorized.tsx` render at 403/401 when `forbidden()` / `unauthorized()` is thrown. These are COMPAT-only (require `compat.next`) and experimental (`authInterrupts`); without compat they fall through to the normal error page.
148
+ ## Intercepting routes
143
149
 
144
- ### Parallel routes
150
+ A marker prefixed to a segment intercepts a route from another level, and is stripped from the resulting path:
145
151
 
146
- A `@slot` directory (e.g. `@team`) contributes no path segment. Its content is rendered and passed to the owning segment's layout as a prop keyed by the slot name (without the `@`), alongside `children`. A slot uses its `page` when present, else its `default` fallback, else renders nothing. A slot's `loading` wraps its content in a Suspense boundary.
152
+ - `(.)segment` intercepts from the same level.
153
+ - `(..)segment` intercepts from one level up.
154
+ - `(..)(..)segment` intercepts from two levels up.
155
+ - `(...)segment` intercepts from the app root.
147
156
 
148
- ### Intercepting routes
149
-
150
- An interception marker (`(.)`, `(..)`, `(..)(..)`, `(...)`) makes the route respond to soft navigations toward the marked target from the intercepting level, usually rendered into a parallel `@slot`. Hard requests render the non-intercepted target. Intercepting routes are always dynamic and never take static params.
151
-
152
- ## Static Params
153
-
154
- Use `params()` on a dynamic route to choose params that `pnext build` should write as static HTML.
155
- Requests outside the returned list keep using dynamic rendering.
156
-
157
- ```tsx
158
- import type { PageProps, StaticParams } from '#gen/app/users/[id]/page'
159
-
160
- export function params(): StaticParams {
161
- return [{ id: 'ada' }, { id: 'grace' }]
162
- }
163
-
164
- export default async function Page({ params }: PageProps) {
165
- return <p>{(await params).id}</p>
166
- }
167
- ```
157
+ The intercepting route answers soft navigations toward the marked target, usually rendering into a parallel slot. A hard request renders the non-intercepted target instead. Intercepting routes are always dynamic and never take static params.
@@ -1,14 +1,17 @@
1
1
  # Type Safety
2
2
 
3
- pnext generates route types from the `app/` tree. These types check route params, static params, route handlers, and typed navigation.
3
+ pnext generates types from the `app/` tree so route params, static params, route handler context, and links are all checked.
4
4
 
5
- ## Route Types
5
+ ## Route types
6
6
 
7
- Import generated types for the current route. pnext validates those exports against the route inferred from the file path, so a wrong param name, static param shape, or route handler context fails TypeScript.
7
+ Each route imports the types generated for its own file path. pnext validates the exports against the route inferred from that path, so a wrong param name, a bad static param shape, or the wrong handler context fails TypeScript.
8
8
 
9
9
  ```tsx
10
10
  // app/users/[id]/page.tsx
11
- import type { PageProps, StaticParams } from '#gen/app/users/[id]/page'
11
+ import type {
12
+ PageProps,
13
+ StaticParams,
14
+ } from '#gen/app/users/[id]/page'
12
15
 
13
16
  export function params(): StaticParams {
14
17
  return [{ id: 'ada' }]
@@ -19,43 +22,28 @@ export default async function Page({ params }: PageProps) {
19
22
  }
20
23
  ```
21
24
 
22
- Route handlers use generated `RouteContext` for the second argument:
25
+ A route handler takes its generated context type as the second argument.
23
26
 
24
27
  ```ts
25
28
  // app/users/[id]/route.ts
26
29
  import type { RouteContext } from '#gen/app/users/[id]/route'
27
30
  import type { NextRequest } from '@wular/pnext/server'
28
31
 
29
- export async function GET(request: NextRequest, { params }: RouteContext) {
32
+ export async function GET(
33
+ request: NextRequest,
34
+ { params }: RouteContext,
35
+ ) {
30
36
  return Response.json({ id: (await params).id })
31
37
  }
32
38
  ```
33
39
 
34
40
  ## Links
35
41
 
36
- Typed links use typed `href` and `params`:
42
+ `<Link>` and `href()` check their route template and params against the same generated types. See [Navigation](./navigation.md).
37
43
 
38
- ```tsx
39
- import { Link } from '@wular/pnext/link'
40
-
41
- export function Nav() {
42
- return (
43
- <Link href="/users/[id]" params={{ id: 'ada' }}>
44
- Ada
45
- </Link>
46
- )
47
- }
48
- ```
49
-
50
- Use a normal `<a>` for external URLs or intentionally untyped links.
51
-
52
- ## Generated Types
44
+ ## Setup
53
45
 
54
- `pnext dev` and `pnext build` refresh generated types automatically. Use `pnext typegen` to refresh them manually.
55
-
56
- Generated files live in `.pnext/types` and should not be edited. Include `.pnext/types/**/*.ts` in the app TypeScript project for editor and `tsc` visibility.
57
-
58
- Use a TypeScript path alias for generated imports:
46
+ Apps from `pnext create` are already set up. The package ships the `@wular/pnext/config/ts/react.json` preset, which the scaffold puts in `extends` alongside the alias for generated types:
59
47
 
60
48
  ```json
61
49
  {
@@ -66,3 +54,9 @@ Use a TypeScript path alias for generated imports:
66
54
  }
67
55
  }
68
56
  ```
57
+
58
+ Include `.pnext/types/**/*.ts` in the app TypeScript project so the editor and `tsc` can see the generated files. `pnext migrate` repoints existing include paths there, but it does not add the alias.
59
+
60
+ ## Regenerating
61
+
62
+ The dev server and the build refresh generated types on their own. Run `pnext typegen` to refresh them by hand. The files live in `.pnext/types` and should not be edited.
@@ -163,18 +163,17 @@ export function useLinkStatus() {
163
163
  }
164
164
 
165
165
  function useLocation() {
166
- const [location, setLocation] = useState(() => currentLocation())
166
+ // Read the URL live each render so any render pairs the address bar with its params;
167
+ // state is only the re-render trigger (unchanged-URL broadcasts bail out).
168
+ const location = currentLocation()
169
+ const [, setLocationKey] = useState(() => locationSignature(location))
167
170
 
168
171
  useEffect(() => {
169
172
  patchHistory()
170
173
  const listener = () =>
171
- setLocation(previous => {
172
- const next = currentLocation()
173
- return previous.pathname === next.pathname &&
174
- previous.search === next.search &&
175
- previous.hash === next.hash
176
- ? previous
177
- : next
174
+ setLocationKey(previous => {
175
+ const next = locationSignature(currentLocation())
176
+ return previous === next ? previous : next
178
177
  })
179
178
  locationListeners().add(listener)
180
179
  window.addEventListener('popstate', listener)
@@ -191,6 +190,10 @@ function useLocation() {
191
190
  return location
192
191
  }
193
192
 
193
+ function locationSignature(location: { pathname: string; search: string; hash: string }) {
194
+ return `${location.pathname}${location.search}${location.hash}`
195
+ }
196
+
194
197
  function currentLocation() {
195
198
  if (!process.browser && typeof window === 'undefined') {
196
199
  return {
package/src/api/link.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { h, type ComponentChildren, type JSX } from 'preact'
2
2
  import { useEffect, useRef } from 'preact/hooks'
3
3
  import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from 'react'
4
- import { routeHref, type SearchInput } from '../routing/href'
4
+ import { getDefaultPrefetchMode, routeHref, type SearchInput } from '../routing/href'
5
5
  import type { PrefetchMode, RouteParams, RoutePath } from '../types'
6
6
 
7
7
  export interface NavigateEvent {
@@ -36,7 +36,7 @@ export type LinkProps<Route extends RoutePath = RoutePath> = BaseLinkProps &
36
36
  export function Link<Route extends RoutePath>({
37
37
  href,
38
38
  params,
39
- prefetch = 'visible',
39
+ prefetch,
40
40
  replace,
41
41
  scroll,
42
42
  search,
@@ -49,6 +49,8 @@ export function Link<Route extends RoutePath>({
49
49
  ...anchorProps
50
50
  }: LinkProps<Route>): ReactNode {
51
51
  const resolvedHref = routeHref(href, { params: params ?? {}, search, hash })
52
+ // A link's own prefetch prop wins; otherwise the app-wide config default.
53
+ const prefetchMode = prefetch ?? getDefaultPrefetchMode()
52
54
  const anchorRef = usePrefetchLifecycle(onPrefetchStart, onPrefetchFinish)
53
55
  return h(
54
56
  'a',
@@ -57,7 +59,7 @@ export function Link<Route extends RoutePath>({
57
59
  ref: anchorRef,
58
60
  href: resolvedHref,
59
61
  'data-pnext-link': true,
60
- 'data-prefetch': prefetch === false ? 'false' : prefetch,
62
+ 'data-prefetch': prefetchMode === false ? 'false' : prefetchMode,
61
63
  'data-pnext-replace': replace ? 'true' : undefined,
62
64
  'data-pnext-scroll': scroll === false ? 'false' : undefined,
63
65
  onClick: event => {
package/src/cli/create.ts CHANGED
@@ -149,6 +149,6 @@ function scaffoldFiles(name: string): Record<string, string> {
149
149
  'app/counter.tsx': `'use client';\n\nimport { useState } from 'preact/hooks';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;\n}\n`,
150
150
  'app/globals.css': `body {\n margin: 0;\n font-family: system-ui, sans-serif;\n}\n`,
151
151
  '.gitignore': `node_modules\n.pnext\n`,
152
- 'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/overview.md\n`,
152
+ 'README.md': `# ${name}\n\nA pnext app.\n\n\`\`\`\nbun install\nbun dev\n\`\`\`\n\nDocs: node_modules/@wular/pnext/reference/getting-started.md\n`,
153
153
  }
154
154
  }
package/src/cli/index.ts CHANGED
@@ -4,6 +4,12 @@ import path from 'node:path'
4
4
  import { markBoot } from './boot/trace'
5
5
  import { commandBinaryName, nameEsbuildProcess, namedBunBinary } from './boot/named-bin'
6
6
 
7
+ // Reached only if something bypassed bin/pnext (which guards the same way).
8
+ if (typeof Bun === 'undefined') {
9
+ console.error('pnext requires Bun. Install it from https://bun.sh/get')
10
+ process.exit(1)
11
+ }
12
+
7
13
  const [, , command, ...args] = process.argv
8
14
  markBoot('cli:entry')
9
15
 
@@ -16,7 +16,7 @@ import {
16
16
  import { foldInitialChunks } from './chunk-fold'
17
17
  import { clientEntryName } from './chunk-name'
18
18
  import { clientProfile } from './profile'
19
- import { publicEnvDefines, type ResolvedConfig } from '../config'
19
+ import { frameworkRuntimeAliasEntries, publicEnvDefines, type ResolvedConfig } from '../config'
20
20
  import { cssModuleClientPlugin } from '../css/build'
21
21
  import { withAssetPrefix } from '../css/build'
22
22
  import {
@@ -1220,7 +1220,15 @@ function clientBuildPlugins(
1220
1220
  }
1221
1221
 
1222
1222
  function importAliasPlugin(config: ResolvedConfig, reactLite = false): Plugin {
1223
- const aliases = { ...getImportAliasExtensions().aliases(config, 'client') }
1223
+ // preact core/hooks/jsx-runtime are single-instance, compat or not: an app with its
1224
+ // own `preact` in node_modules otherwise bundles a second physical copy next to the
1225
+ // framework's, and hooks called from framework components (Link) read a null current
1226
+ // component off options the app's copy never installed. The server runtime pins the
1227
+ // same set unconditionally (loader's coreAliases); the client build must match.
1228
+ const aliases: Record<string, string> = {
1229
+ ...frameworkRuntimeAliasEntries(),
1230
+ ...getImportAliasExtensions().aliases(config, 'client'),
1231
+ }
1224
1232
  // Suspense-free tier: the app's `react` imports resolve to the compat-free lite shim, so the
1225
1233
  // bundle ships preact core + hooks without preact/compat (see clientSuspenseFree).
1226
1234
  if (reactLite && aliases.react) {
@@ -22,9 +22,24 @@ export function locationListeners() {
22
22
 
23
23
  export function emitLocationChange() {
24
24
  routerState.observedLocationKey = locationKey()
25
+ if (silentLocationDepth > 0) return
25
26
  for (const listener of [...locationListeners()]) listener()
26
27
  }
27
28
 
29
+ let silentLocationDepth = 0
30
+
31
+ // Moves the address bar without waking usePathname/useParams subscribers: the pre-commit
32
+ // optimistic push's URL is ahead of the tree, so broadcasting would desync URL and params.
33
+ // observedLocationKey still advances, so a traversal off the pushed entry is a real move.
34
+ export function withSilentLocationChange(move: () => void) {
35
+ silentLocationDepth++
36
+ try {
37
+ move()
38
+ } finally {
39
+ silentLocationDepth--
40
+ }
41
+ }
42
+
28
43
  // Fires at the start of every soft navigation (link click, router.push,
29
44
  // refresh). Compat's link-status uses it to end a link's pending state when a
30
45
  // different navigation supersedes it.
@@ -31,11 +31,13 @@ import {
31
31
  emitNavigationCommit,
32
32
  emitNavigationStart,
33
33
  scheduleNavigationScroll,
34
+ withSilentLocationChange,
34
35
  } from './events'
35
36
  import type {
36
37
  ClientPageRoot,
37
38
  DocumentNavState,
38
39
  EntryModule,
40
+ LinkPrefetchMode,
39
41
  LoadingShellPrediction,
40
42
  PrefetchedPage,
41
43
  PrefetchOptions,
@@ -4059,9 +4061,13 @@ async function pageForNavigation(
4059
4061
  // Reuse within the staleTime window (the entry is kept warm, not one-shot). A failed
4060
4062
  // entry is dropped. A shell-only (partial prefetch) entry never commits as a document -
4061
4063
  // its loading shell was painted above; fall through to the real fetch.
4062
- const page = cached.settled
4063
- ? await cached.page
4064
- : await Promise.race([cached.page, unsettledPrefetchDeadline()])
4064
+ // A full (`prefetch={true}`) prefetch is a complete document for this navigation:
4065
+ // attach to it on its own settle signal (cancel/error resolve null), never a wall
4066
+ // clock - a slow machine must not make the router duplicate the request.
4067
+ const page =
4068
+ cached.settled || cached.full
4069
+ ? await cached.page
4070
+ : await Promise.race([cached.page, unsettledPrefetchDeadline()])
4065
4071
  if (page && !page.shellOnly) return page
4066
4072
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
4067
4073
  // the navigation issued no duplicate fetch, so paint the static stage it landed and let
@@ -4523,14 +4529,24 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4523
4529
  // shallow same-entry move in onPopState and get dropped, stranding the UI on the
4524
4530
  // half-committed target. The commit below reuses the id.
4525
4531
  let optimisticEntryId: string | undefined
4526
- const pushOptimisticUrl = () => {
4532
+ // `silent` moves the address bar without broadcasting: used before the tree is painted, where a
4533
+ // location broadcast would render the destination URL against the departing route's params.
4534
+ const pushOptimisticUrl = (silent = false) => {
4527
4535
  if (optimisticallyPushed || options.pop || refreshLike) return
4528
4536
  if (url.pathname === location.pathname && url.search === location.search) return
4529
4537
  optimisticallyPushed = true
4530
4538
  optimisticEntryId = routerState.renderedEntryId = newEntryId()
4531
4539
  const shellState = { ...historyState(), __pnextEntry: optimisticEntryId }
4532
- if (options.replace) history.replaceState(shellState, '', url.href)
4533
- else history.pushState(shellState, '', url.href)
4540
+ const move = () => {
4541
+ if (options.replace) history.replaceState(shellState, '', url.href)
4542
+ else history.pushState(shellState, '', url.href)
4543
+ }
4544
+ if (silent) withSilentLocationChange(move)
4545
+ else move()
4546
+ // Record the observed URL without emitting; a stale key would make a back() off this
4547
+ // entry compare equal to the departing URL and read as a shallow move, so onPopState
4548
+ // would leave the DOM alone and this navigation would paint over the popped entry.
4549
+ routerState.observedLocationKey = locationKey()
4534
4550
  }
4535
4551
  // A forward navigation to a route with a loading boundary streams shell-first, so paint
4536
4552
  // that fallback into the current page container as soon as the shell chunk arrives. Skipped
@@ -4696,6 +4712,12 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4696
4712
  return
4697
4713
  }
4698
4714
 
4715
+ // All hard-navigate bailouts are behind us: open the history entry NOW, before the asset
4716
+ // warm-up awaits the network - a back() in that window would otherwise escape the app
4717
+ // (no entry pushed yet). The final commit replaces this entry, correcting redirects. Silent:
4718
+ // nothing has painted yet, so subscribers must keep seeing the departing route until commit.
4719
+ pushOptimisticUrl(true)
4720
+
4699
4721
  // Warm the new document's assets before touching the current one: the swap
4700
4722
  // then paints styled content immediately instead of flashing unstyled HTML.
4701
4723
  const entrySrc = entryScriptSrc(doc)
@@ -5005,6 +5027,16 @@ function isFullPrefetchLink(link: Element): boolean {
5005
5027
  return link.getAttribute('data-prefetch-full') === 'true'
5006
5028
  }
5007
5029
 
5030
+ // The mode a link prefetches in. Its own `data-prefetch` always wins; a plain
5031
+ // `data-pnext-link` anchor without one takes the app-wide config default
5032
+ // (`window.__PNEXT_PREFETCH__`, injected by the server), else 'visible'.
5033
+ export function linkPrefetchMode(link: Element): LinkPrefetchMode {
5034
+ const attribute = link.getAttribute('data-prefetch')
5035
+ if (attribute === null)
5036
+ return (typeof window === 'undefined' ? undefined : window.__PNEXT_PREFETCH__) ?? 'visible'
5037
+ return attribute === 'false' ? false : (attribute as LinkPrefetchMode)
5038
+ }
5039
+
5008
5040
  // Pointer-intent state. True once the pointer has moved since the last pointerdown -
5009
5041
  // distinguishes a real hover from content swapping in under a stationary cursor. Starts true
5010
5042
  // so a hover before any click counts. Because boundary events precede the pointermove of the
@@ -5029,8 +5061,7 @@ function onLinkIntent(event: Event) {
5029
5061
  }
5030
5062
  const link = linkFromEvent(event)
5031
5063
  if (!link) return
5032
- const mode = link.getAttribute('data-prefetch')
5033
- if (mode === 'false') return
5064
+ if (linkPrefetchMode(link) === false) return
5034
5065
  const href = link.getAttribute('href')
5035
5066
  // unstable_dynamicOnHover: hover intent upgrades the partial (viewport)
5036
5067
  // prefetch to a full one carrying the dynamic data — served as a resume-only
@@ -5128,7 +5159,7 @@ function scanEagerPrefetchLinks(root: Element) {
5128
5159
  const links = [...root.querySelectorAll<HTMLAnchorElement>('a[data-pnext-link]')]
5129
5160
  if (root.matches('a[data-pnext-link]')) links.push(root as HTMLAnchorElement)
5130
5161
  for (const link of links) {
5131
- const mode = link.getAttribute('data-prefetch')
5162
+ const mode = linkPrefetchMode(link)
5132
5163
  if (mode !== 'load' && mode !== 'visible') continue
5133
5164
  if (eagerLinks.has(link)) continue
5134
5165
  eagerLinks.add(link)
@@ -152,8 +152,13 @@ export interface EntryModule {
152
152
  mountRoute?: () => Promise<unknown> | void
153
153
  }
154
154
 
155
+ /** Mirror of core's PrefetchMode; the router keeps no imports outside its chunk. */
156
+ export type LinkPrefetchMode = false | 'intent' | 'visible' | 'load'
157
+
155
158
  declare global {
156
159
  interface Window {
160
+ /** App-wide default prefetch mode (config `prefetch`), injected by the server. */
161
+ __PNEXT_PREFETCH__?: LinkPrefetchMode
157
162
  __PNEXT_ROUTER_INSTALLED__?: boolean
158
163
  __PNEXT_ROUTER_IMPORTS__?: number
159
164
  __PNEXT_ACTIVE_ENTRY__?: ActiveEntry