@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,155 +1,34 @@
1
1
  # Performance
2
2
 
3
- Measured against Next.js: the same fixture source runs under both frameworks and `bun bench` measures them side by side, on a Blacksmith 4-vCPU CI runner (full machine details below). The ranges below span the fixtures hello-world, an SSR site, and a mid-size admin dashboard (30 routes, 18 client islands):
3
+ Measured results against Next.js, and what a route ships to the browser. The numbers come from running the same hello-world, SSR, and 30-route dashboard fixtures under both frameworks. Ratios are Next.js time or memory divided by pnext, so a larger number favors pnext.
4
4
 
5
- | | pnext vs Next.js |
6
- | --------------------------- | ---------------------------------: |
7
- | Dev server ready | **3.8–4.3× faster** |
8
- | First page, cold | **10–12× faster** |
9
- | Warm request (dev) | **1.64.7× faster** |
10
- | HMR save visible | **0.32×** ¹ |
11
- | Production build | **7.2–8.8× faster** |
12
- | Warm request (prod) | **2–3.4× faster** |
13
- | Prod server ready | **1.8–2.3× faster** |
14
- | Dev server memory | **3.4–4.1× less** |
15
- | Build peak memory | **3–3.7× less** |
16
- | Framework install size | **59× smaller** (7.1 MB vs 421 MB) |
17
- | First-page client JS (gzip) | **9.5–65× less** |
5
+ | Metric | Result across the three fixtures |
6
+ | ----------------------------- | ------------------------------------------------------------------------------------- |
7
+ | Dev first page HTML | **10.112.4× faster** |
8
+ | HMR save to visible content | **2.0× faster** on hello-world; **3.1–3.9× slower** on the SSR and dashboard fixtures |
9
+ | Production build time | **7.28.8× faster** |
10
+ | Dev server memory | **3.44.1× less** |
11
+ | Production cold start (ready) | **1.8–2.3× faster** |
18
12
 
19
- ¹ pnext live-reloads (no client HMR runtime; state doesn't survive a save) — the metric
20
- is save → fresh HTML, which both models support. Scoped invalidation fixed the small
21
- fixture (21.9 ms, ahead of Next); the larger fixtures still trail on this runner
22
- (264/300 ms vs 68/98 ms) and the remaining cost is under investigation.
13
+ For interactive tables and per-fixture values, see [pnext.dev/benchmarks](https://www.pnext.dev/benchmarks).
23
14
 
24
- Nothing is prebundled the dev server compiles what a request needs and caches it content-addressed, which is why readiness doesn't scale with app size. The production server idles at parity with Next (~85–87 MB) while answering warm requests 2–3.4× faster.
15
+ ## What ships to the browser
25
16
 
26
- The tables below are one full run's absolute numbers, pasted from `bun bench` output (the CI run's `bench-output` artifact carries the same data as `bench.json`).
17
+ Server-only routes ship 0 KB of route JavaScript, because they need no browser runtime to render their content.
27
18
 
28
- - Date: 2026-08-20 (actions run 32364720143)
29
- - Machine: CI — Blacksmith 4 vCPU (Intel Xeon), 16 GB RAM, Ubuntu 22.04 x64
30
- - Bun: 1.3.10
31
- - Next.js: 16.2.12
32
- - Runs per metric: 5 (first discarded), medians reported
19
+ For client navigation and prefetching, the prefetch-only runtime is 348 B gzip. The combined router and hydrator runtime is 4.47 KB gzip.
33
20
 
34
- Every fixture under `bench/fixtures/` runs unmodified on both frameworks, so the
35
- two columns render the same app from the same source.
21
+ Routes using the Next compatibility layer carry its navigation client, so their client-JS numbers are not the core zero-JS case.
36
22
 
37
- ## Fixture: hello-world
23
+ ## The HMR result
38
24
 
39
- | Metric | pnext | Next.js | Ratio |
40
- | -------------------------------- | -------- | --------- | ------- |
41
- | Dev cold start (ready) | 56.4 ms | 212.3 ms | 3.76x |
42
- | Dev first page HTML | 79.9 ms | 993.5 ms | 12.44x |
43
- | Dev warm request (p50 of 7) | 3.0 ms | 14.0 ms | 4.69x |
44
- | Dev server RSS (ready + 7 warm) | 130.8 MB | 536.4 MB | 4.10x |
45
- | HMR save → visible | 21.9 ms | 44.8 ms | 2.04x |
46
- | Prod build (wall) | 291.3 ms | 2569.4 ms | 8.82x |
47
- | Prod build peak RSS | 124.2 MB | 455.8 MB | 3.67x |
48
- | Prod start (ready) | 60.7 ms | 110.9 ms | 1.83x |
49
- | Prod warm request (p50 of 7) | 0.6 ms | 1.2 ms | 2.05x |
50
- | Prod server RSS (ready + 7 warm) | 85.2 MB | 85.7 MB | 1.01x |
51
- | Framework install size | 7.1 MB | 420.7 MB | 59.14x |
52
- | First-page client JS (raw) | 4.43 KB | 502.47 KB | 113.36x |
53
- | First-page client JS (gzip) | 2.19 KB | 141.78 KB | 64.67x |
54
- | First-page JS files | 3 | 5 | 1.67x |
55
- | Zero-island route client JS | 4.43 KB | 502.47 KB | 113.36x |
25
+ HMR is slower than Next.js on the larger fixtures and is under investigation. pnext uses live reload rather than a client HMR runtime, so component state does not survive a save. The measurement above is the time until the page serves fresh visible content.
56
26
 
57
- Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
27
+ ## How these were measured
58
28
 
59
- ## Fixture: ssr
29
+ These results are from **2026-08-20**, using Bun **1.3.10** and Next.js **16.2.12** on a Blacksmith 4-vCPU (Intel Xeon), 16 GB RAM, Ubuntu 22.04 x64 CI runner. Each metric used five runs, discarded the first, and reports medians.
60
30
 
61
- | Metric | pnext | Next.js | Ratio |
62
- | -------------------------------- | -------- | --------- | ------- |
63
- | Dev cold start (ready) | 56.0 ms | 212.8 ms | 3.80x |
64
- | Dev first page HTML | 93.5 ms | 1043.3 ms | 11.16x |
65
- | Dev warm request (p50 of 7) | 16.5 ms | 26.4 ms | 1.60x |
66
- | Dev server RSS (ready + 7 warm) | 151.3 MB | 553.6 MB | 3.66x |
67
- | HMR save → visible | 264.3 ms | 68.3 ms | 0.26x |
68
- | Prod build (wall) | 310.5 ms | 2738.8 ms | 8.82x |
69
- | Prod build peak RSS | 136.8 MB | 456.6 MB | 3.34x |
70
- | Prod start (ready) | 57.1 ms | 105.5 ms | 1.85x |
71
- | Prod warm request (p50 of 7) | 0.5 ms | 1.3 ms | 2.50x |
72
- | Prod server RSS (ready + 7 warm) | 86.0 MB | 85.9 MB | 1.00x |
73
- | Framework install size | 7.1 MB | 420.7 MB | 59.14x |
74
- | First-page client JS (raw) | 27.36 KB | 502.78 KB | 18.37x |
75
- | First-page client JS (gzip) | 11.39 KB | 142.03 KB | 12.47x |
76
- | First-page JS files | 4 | 6 | 1.50x |
77
- | Zero-island route client JS | 4.46 KB | 502.47 KB | 112.56x |
78
-
79
- Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
80
-
81
- ## Fixture: dashboard
82
-
83
- | Metric | pnext | Next.js | Ratio |
84
- | -------------------------------- | -------- | --------- | ------ |
85
- | Dev cold start (ready) | 57.1 ms | 246.4 ms | 4.31x |
86
- | Dev first page HTML | 127.0 ms | 1285.8 ms | 10.12x |
87
- | Dev warm request (p50 of 7) | 21.6 ms | 34.1 ms | 1.58x |
88
- | Dev server RSS (ready + 7 warm) | 168.6 MB | 577.1 MB | 3.42x |
89
- | HMR save → visible | 300.4 ms | 97.6 ms | 0.32x |
90
- | Prod build (wall) | 637.5 ms | 4566.3 ms | 7.16x |
91
- | Prod build peak RSS | 188.0 MB | 569.0 MB | 3.03x |
92
- | Prod start (ready) | 55.6 ms | 128.8 ms | 2.32x |
93
- | Prod warm request (p50 of 7) | 0.5 ms | 1.7 ms | 3.41x |
94
- | Prod server RSS (ready + 7 warm) | 87.3 MB | 90.6 MB | 1.04x |
95
- | Framework install size | 7.1 MB | 420.7 MB | 59.14x |
96
- | First-page client JS (raw) | 38.51 KB | 507.42 KB | 13.18x |
97
- | First-page client JS (gzip) | 15.26 KB | 144.85 KB | 9.50x |
98
- | First-page JS files | 6 | 9 | 1.50x |
99
-
100
- Ratio is Next.js / pnext, so above `1.00x` means pnext is ahead.
101
-
102
- ## Client runtime bundles
103
-
104
- | Runtime | Raw | Gzip | Brotli |
105
- | ------------------------ | -------- | ------- | ------- |
106
- | router-prefetch-only | 473 B | 348 B | 283 B |
107
- | combined-router-hydrator | 10.47 KB | 4.47 KB | 4.07 KB |
108
-
109
- ## Targets
110
-
111
- | Target | Limit | Measured | Status |
112
- | ------------------------------------------ | ---------------- | --------------------- | ------------- |
113
- | Dev cold start, ssr fixture (pnext) | <= 100 ms | 56.0 ms | PASS |
114
- | Router runtime | <= 1.00 KB gzip | 348 B gzip | PASS |
115
- | Hydrated-route framework tax | <= 5.00 KB gzip | 4.47 KB gzip | PASS |
116
- | Zero-island route client JS (ssr `/about`) | 0 B (core pnext) | 4.46 KB (compat.next) | not exercised |
117
-
118
- ## Notes
119
-
120
- - First-page client JS counts the `<script src>` and `rel="modulepreload"` assets the
121
- route executes, fetched from each framework's own production server; `noModule`
122
- polyfills and speculative `rel="preload"` hints for other routes are excluded on both sides.
123
- - Dev cold start wipes the output directory, spawns the dev server on a free port and
124
- stops at the readiness banner; first page HTML is the GET that follows it, so it
125
- includes on-demand compilation.
126
- - HMR appends a marker to a rendered string in a page component and polls until the
127
- page serves it back. A 200 with stale HTML does not count. pnext applies the save via
128
- live-reload (full document refresh, no client HMR runtime); Next applies it via Fast
129
- Refresh. The metric is model-agnostic: time until fresh content is served.
130
- - Fixtures enable `compat.next` so one source tree runs on both frameworks. That ships
131
- pnext's Next-compat navigation client, which a core pnext app does not carry — the
132
- 0 B zero-island budget is a core-pnext invariant this suite does not exercise.
133
-
134
- ## Memory
135
-
136
- - RSS is summed across the whole process tree (parent + spawned workers), read once at a
137
- fixed point: right after the ready signal and the 7 warm requests, dev and prod alike.
138
- It is never sampled at an arbitrary time, since RSS is pressure-sensitive.
139
- - Build peak RSS comes from `/usr/bin/time` wrapping the build process directly (`-l` on
140
- macOS, `-v` on Linux), not the tree-sum helper — it is the OS-reported peak over the
141
- whole build, not a single snapshot. The framework server-entry prebundle is emitted by
142
- a short-lived child whose transient RSS (~15–25 MB, returned at exit) this number
143
- excludes.
144
- - Framework install size is each framework's own package cost, not the fixture's total
145
- `node_modules`, which both frameworks share: `next` + its platform `@next/swc-*` binary,
146
- or `@wular/pnext`'s npm-publish footprint (its `package.json` "files" list, since the
147
- repo resolves it to source rather than an installed build) + `preact`.
148
-
149
- ## Commands
150
-
151
- ```sh
152
- bun bench # every fixture, both frameworks
153
- bun bench/run.ts ssr # one fixture
154
- bun bench/run.ts all --framework=pnext --runs=5
155
- ```
31
+ - The fixtures use the same source under both frameworks.
32
+ - Dev first page HTML includes on-demand compilation after a cold server start.
33
+ - Dev memory is summed across the server process tree after readiness and seven warm requests.
34
+ - Build memory is the operating system's reported peak RSS.
@@ -1,20 +1,10 @@
1
1
  # Rendering
2
2
 
3
- pnext renders on the server first and sends browser JavaScript only for interactive components.
3
+ Where a component runs, what crosses between server and browser, and how to stream or defer work. pnext renders on the server first and sends browser JavaScript only for the components that need it.
4
4
 
5
5
  ## Server Components
6
6
 
7
- Pages and layout components are Server Components. They run only on the server, so they can read files, query databases, call internal services, and use server-only dependencies without adding that code to the browser bundle.
8
-
9
- A `layout.tsx` file does not need a default export. Use metadata-only layouts when a segment only needs to set page metadata.
10
-
11
- Use Server Components for data loading and non-interactive UI:
12
-
13
- - Render HTML from server data.
14
- - Keep expensive parsing, formatting, and data libraries on the server.
15
- - Pass serializable props to Client Components for interactive parts.
16
- - Render Client Components inside Server Components when part of the page needs interactivity.
17
- - Read request data from explicit page/layout props when a route depends on headers or cookies.
7
+ Pages and layouts are Server Components. They run only on the server, so they can read files, query databases, call internal services, and use server-only dependencies without any of that reaching the browser bundle. They can be async, and static builds wait for the final HTML.
18
8
 
19
9
  ```tsx
20
10
  export default async function Page() {
@@ -23,105 +13,118 @@ export default async function Page() {
23
13
  }
24
14
  ```
25
15
 
26
- Server components can be async. Static builds wait for the final HTML.
27
-
28
- Pages and layouts that read `request` receive a `NextRequest` compatible object. It extends the standard Request with convenience access for cookies and headers.
16
+ A layout file does not need a default export. Use a metadata-only layout when a segment only needs to set page metadata.
29
17
 
30
- Use `request.cookies.get()`, `request.headers.get()`, `request.method`, and `request.url` from props. It keeps static routes static and makes request-dependent routes dynamic.
31
-
32
- Apps using `compat.next` can also import `cookies()` and `headers()` from `next/headers`.
33
-
34
- ## Document
18
+ ## Client Components
35
19
 
36
- Use the root `layout.tsx` to own `<html>` and `<body>`:
20
+ A `"use client"` directive marks a component that needs browser APIs, hooks, or event handlers. Server Components can render them. They render HTML on the server, then hydrate in the browser.
37
21
 
38
22
  ```tsx
39
- import type { LayoutProps } from '@wular/pnext'
23
+ 'use client'
40
24
 
41
- export default function Layout({ children }: LayoutProps) {
25
+ import { useState } from 'preact/hooks'
26
+
27
+ export function Counter({ initial }: { initial: number }) {
28
+ const [count, setCount] = useState(initial)
42
29
  return (
43
- <html lang="en">
44
- <body>{children}</body>
45
- </html>
30
+ <button onClick={() => setCount(count + 1)}>Count {count}</button>
46
31
  )
47
32
  }
48
33
  ```
49
34
 
50
- If the root layout exports a default component, it must return `<html>` and `<body>`.
51
- If the root layout only exports metadata, pnext creates the document shell.
35
+ Files imported by a client component become client code automatically. Add the directive to an imported file only when a server component also renders it directly.
52
36
 
53
- ### Metadata
37
+ A Client Component can wrap server-rendered children. Those children stay server-rendered, and only the wrapper hydrates.
54
38
 
55
- Pages and layouts can export core build-time `metadata`, `metadata()`, `viewport`, and `viewport()`. See [Metadata](./metadata.md) for the full core metadata model.
39
+ Put the directive at the top of a page when the whole page needs browser-only behavior. Prefer smaller client components when only part of the page is interactive.
56
40
 
57
- ## Request Cache
41
+ ## Lazy loading
58
42
 
59
- Use server-only `cache()` to dedupe repeated server work during one render or route-handler request:
43
+ `dynamic()` defers loading a component. It works in Server Components and Client Components alike, and inside a Client Component it is a browser lazy loader. The loader can also be a literal module path for a default export.
60
44
 
61
45
  ```tsx
62
- import { cache } from '@wular/pnext/cache'
46
+ import { dynamic } from '@wular/pnext/dynamic'
63
47
 
64
- export const getPost = cache(async (id: string) => {
65
- return db.post.findUnique({ where: { id } })
48
+ const Chart = dynamic(() => import('./chart').then(m => m.Chart), {
49
+ load: 'visible',
66
50
  })
67
51
  ```
68
52
 
69
- Calls to the same cached function with the same arguments share the result inside the current request. The cache is cleared between requests, so it does not share data across users, is not a persistent data cache, and should not be imported from Client Components.
53
+ A Client Component target renders on the server and hydrates by default. The options change that:
70
54
 
71
- ## Client Components
55
+ - `ssr: false` produces browser-only output.
56
+ - `load: 'visible'` waits for the island to enter view, and accepts `rootMargin` and `threshold`. It is browser-only as well unless `ssr` is true.
57
+ - `loading` supplies fallback UI.
72
58
 
73
- Add `"use client"` to components that need browser APIs, hooks, or event handlers.
59
+ ## Props that cross the boundary
74
60
 
75
- ```tsx
76
- // app/counter.tsx
77
- 'use client'
61
+ Props passed from a server component to a client component must be serializable.
78
62
 
79
- import { useState } from 'preact/hooks'
63
+ - Accepted: strings, numbers, booleans, `null`, `undefined`, arrays, plain objects, `Map`, `Set`, typed arrays, and `ArrayBuffer`.
64
+ - Rejected: functions, symbols, `bigint`, and class instances such as `Date` and `URL`. Pass those as strings.
80
65
 
81
- export function Counter({ initial }: { initial: number }) {
82
- const [count, setCount] = useState(initial)
83
- return <button onClick={() => setCount(count + 1)}>Count {count}</button>
84
- }
85
- ```
66
+ Self-referencing values are fine, because they are written as back-references.
86
67
 
87
- ```tsx
88
- // app/page.tsx
89
- import { Counter } from './counter'
68
+ ## Reading request data
90
69
 
91
- export default function Page() {
92
- return <Counter initial={0} />
93
- }
70
+ The request prop on a page or layout is a `NextRequest` compatible object, which extends the standard Request with convenience access for cookies and headers, plus method and URL.
71
+
72
+ ```tsx
73
+ const theme = request?.cookies.get('theme')?.value
94
74
  ```
95
75
 
96
- Files imported by a client component are client code automatically. Add `"use client"` to an imported file only when it is also rendered directly from a server component.
76
+ Reading it keeps static routes static and marks request-dependent routes dynamic. Apps using `compat.next` can import `cookies()` and `headers()` from `next/headers` instead.
97
77
 
98
- Props passed from server components to client components must be serializable. pnext accepts strings, numbers, booleans, `null`, `undefined`, arrays, plain objects, `Map`, `Set`, typed arrays, and `ArrayBuffer`. Self-referencing values are fine — they are written as back-references. It rejects functions, symbols, `bigint`, and class instances, including `Date` and `URL`; pass those as strings.
78
+ ## Streaming
99
79
 
100
- Server Components can render Client Components. Client Components render HTML on the server, then hydrate in the browser. Client Components can wrap server-rendered children; those children stay server-rendered and only the wrapper hydrates in the browser.
80
+ A Suspense boundary sends fallback HTML while the server finishes the content inside it.
101
81
 
102
- Use `dynamic({ ssr: false })` for browser-only Client Components. `dynamic({ load: 'visible' })` is browser-only by default, so it can keep Preact and the component chunk out of the initial route bundle until the island enters view.
82
+ ```tsx
83
+ <Suspense fallback={<p>Loading comments...</p>}>
84
+ <Comments />
85
+ </Suspense>
86
+ ```
103
87
 
104
- If the whole page needs browser-only behavior, put `"use client"` at the top of the page. Prefer smaller client components when only part of the page is interactive.
88
+ Dev and server responses stream the fallback first, then replace it when the content is ready. Static builds wait for the final content.
105
89
 
106
- ## Streaming
90
+ pnext exports `Suspense` and `SuspenseProps` from `@wular/pnext` and resolves these boundaries in its own renderer, so core apps need nothing from `preact/compat`. Apps with `compat.react` can keep importing React's `Suspense`, since both identities are recognized.
91
+
92
+ A segment `loading` file creates one of these boundaries automatically. See [Routing](./routing.md#convention-files).
93
+
94
+ ## Request cache
107
95
 
108
- Wrap slower server content in `<Suspense>` to show fallback HTML while the server finishes rendering the rest. The pnext renderer resolves these boundaries itself, so core apps use pnext's own `Suspense` no `preact/compat` involved. Apps with `compat.react` can keep importing React's `Suspense`; both identities are recognized.
96
+ `cache()` dedupes repeated work inside one render or route-handler request.
109
97
 
110
98
  ```tsx
111
- import { Suspense } from '@wular/pnext'
99
+ import { cache } from '@wular/pnext/cache'
112
100
 
113
- async function Comments() {
114
- const comments = await getComments()
115
- return <CommentList comments={comments} />
116
- }
101
+ export const getPost = cache(async (id: string) =>
102
+ db.post.findUnique({ where: { id } }),
103
+ )
104
+ ```
117
105
 
118
- export default function Page() {
106
+ Calls with the same arguments share one result. The cache is cleared between requests, so it never shares data across users. It is not a persistent data cache, and it should not be imported from Client Components.
107
+
108
+ ## Root layout
109
+
110
+ The root layout owns the document. One with a default component must return `<html>` and `<body>`. One that only exports metadata gets a document shell from pnext. For the head itself, see [Metadata](./metadata.md).
111
+
112
+ ```tsx
113
+ import type { LayoutProps } from '@wular/pnext'
114
+
115
+ export default function Layout({ children }: LayoutProps) {
119
116
  return (
120
- <Suspense fallback={<p>Loading comments...</p>}>
121
- <Comments />
122
- </Suspense>
117
+ <html lang="en">
118
+ <body>{children}</body>
119
+ </html>
123
120
  )
124
121
  }
125
122
  ```
126
123
 
127
- Dev and server responses can stream the fallback first, then replace it when the server content is ready. Static builds wait for the final content.
124
+ ## Experimental: partial prerendering and cacheComponents
125
+
126
+ A page that uses request data and exports `experimental_ppr` produces a static shell with postponed Suspense content that is resumed for the request. The renderer represents postponed work with a `PostponeError`, so wrap that content in Suspense rather than handling the error yourself.
127
+
128
+ `cacheComponents` requires `compat.next` and is set in the loaded Next configuration. It makes the shell-and-resume path the default, and turns request APIs into values that postpone when first awaited inside Suspense during prerendering: `cookies()`, `headers()`, `connection()`, route params, search params, and `io()`. The `io()` helper comes from the compat API and has no effect unless cacheComponents is enabled.
129
+
130
+ Cache lifetimes and cache tags recorded during prerendering are honored when prebuilt output is served, including partial shells. Reach them through the Next `cacheLife()` and cache-tag APIs, which compat supplies.