@wular/pnext 0.0.6 → 0.0.8

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 => {