@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.
- package/README.md +12 -6
- package/bin/pnext +5 -0
- package/package.json +1 -1
- package/reference/compat.md +86 -21
- package/reference/config.md +40 -93
- package/reference/css.md +40 -35
- package/reference/dev.md +25 -32
- package/reference/env.md +18 -20
- package/reference/getting-started.md +132 -0
- package/reference/metadata.md +26 -41
- package/reference/navigation.md +58 -105
- package/reference/performance.md +21 -142
- package/reference/rendering.md +73 -70
- package/reference/routing.md +87 -97
- package/reference/typegen.md +21 -27
- package/src/api/client-navigation.ts +11 -8
- package/src/api/link.tsx +5 -3
- package/src/cli/create.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/client/build.ts +10 -2
- package/src/client/router/events.ts +15 -0
- package/src/client/router/runtime.ts +40 -9
- package/src/client/router/types.ts +5 -0
- package/src/config.ts +18 -1
- package/src/dev/server.ts +2 -2
- package/src/render/renderer.ts +11 -0
- package/src/routing/href.ts +19 -1
- package/src/runtime/loader.ts +3 -1
- package/src/runtime/vendor.ts +5 -4
- package/src/types.ts +5 -1
- package/reference/overview.md +0 -35
package/reference/routing.md
CHANGED
|
@@ -1,39 +1,46 @@
|
|
|
1
1
|
# Routing
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
##
|
|
16
|
+
## Convention files
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
is stripped from the resulting path. See [Intercepting routes](#intercepting-routes).
|
|
32
|
+
Behavior worth knowing:
|
|
30
33
|
|
|
31
|
-
- `
|
|
32
|
-
- `
|
|
33
|
-
- `
|
|
34
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
+
## Route handlers
|
|
51
58
|
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
75
|
+
## Static and dynamic routes
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
pnext infers rendering from what a route uses:
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
- Static: no dynamic params and no request prop usage.
|
|
80
|
+
- Dynamic: request prop usage or dynamic route segments.
|
|
78
81
|
|
|
79
|
-
|
|
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
|
|
84
|
+
## Route segment config
|
|
83
85
|
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
134
|
-
Validate redirect targets that come from user input.
|
|
140
|
+
## Static params
|
|
135
141
|
|
|
136
|
-
`
|
|
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
|
-
|
|
144
|
+
## Parallel routes
|
|
139
145
|
|
|
140
|
-
`
|
|
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
|
-
|
|
148
|
+
## Intercepting routes
|
|
143
149
|
|
|
144
|
-
|
|
150
|
+
A marker prefixed to a segment intercepts a route from another level, and is stripped from the resulting path:
|
|
145
151
|
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
package/reference/typegen.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# Type Safety
|
|
2
2
|
|
|
3
|
-
pnext generates
|
|
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
|
|
5
|
+
## Route types
|
|
6
6
|
|
|
7
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
42
|
+
`<Link>` and `href()` check their route template and params against the same generated types. See [Navigation](./navigation.md).
|
|
37
43
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
const next = currentLocation()
|
|
173
|
-
return previous
|
|
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
|
|
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':
|
|
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/
|
|
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
|
|
package/src/client/build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4533
|
-
|
|
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
|
-
|
|
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
|
|
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
|