mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
d6bf6413e9
* feat(pages-router): consume _next/data JSON endpoint from the client
The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in
#1384, but the vinext client was still fetching the full HTML page on
every navigation and regex-extracting `__NEXT_DATA__` from it. This
wires the client to actually use the JSON endpoint, matching what
Next.js does and avoiding the HTML round trip.
## Client navigation (shims/router.ts)
`navigateClient` is split into two paths:
- `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has
a registered code-split loader for the target route AND we have a
buildId, fetch `/_next/data/<buildId>/<page>.json` with
`Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the
envelope, look up the page module via the in-memory loader map (Vite
has already split each page into its own chunk), and re-render.
- `navigateClientHtml`: the previous HTML-fetch + regex-extract logic,
unchanged. Used in dev (where the inline hydration script does not
populate the loader map) and as a fallback for any route not in the
client loader map.
Failure modes — 404, 5xx, network error, malformed JSON, soft
redirect via `x-nextjs-redirect`, missing loader — all queue a hard
navigation (`window.location.href = url`). This is the deploy-skew
safety net: when the server's buildId has rotated, the data endpoint
returns 404 and the client lands on the new build via a full document
load.
## Code-split loader manifest (entries/pages-client-entry.ts)
Vite already code-splits each page into its own chunk via the dynamic
imports in `pageLoaders`. The generated client entry now exposes that
map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk),
plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and
`window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This
replaces Next.js' `_buildManifest.js` indirection — vinext already
generates the equivalent at build time, we just had to expose it.
## Data URL construction (shims/internal/pages-data-url.ts)
New pure helper module mirroring `getAssetPathFromRoute` +
`getDataHref` from Next.js. Handles:
- `/` → `/_next/data/<id>/index.json`
- `/about` → `/_next/data/<id>/about.json`
- `/index` → `/_next/data/<id>/index/index.json` (round-trip)
- `/blog/foo` → `/_next/data/<id>/blog/foo.json`
Also exports `matchPagesPattern` which uses the existing
`matchRoutePattern` to map a URL pathname to a registered route
pattern.
Round-trip tests verify the server's `parseNextDataPathname` agrees
with the client's `buildPagesDataPath` for every shape, and surfaced a
latent server bug: `/_next/data/<id>/index/foo.json` was parsing back
to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so
the round-trip is now lossless.
## Prefetch (shims/link.tsx + shims/router.ts)
`<Link>` prefetch and `Router.prefetch()` now prefer the data path:
- Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>`
for the JSON, matching the actual navigation's request shape.
- Invoke the loader thunk to warm the chunk in parallel. Vite's
`import()` caches the result, so the eventual click is a double
cache hit.
When no loader matches (dev, unmapped route), fall back to the legacy
`<link rel="prefetch" as="document">` so the browser at least preloads
the HTML.
Notably this also fixes a long-standing prefetch bug: the previous
Pages branch of `link.tsx`'s prefetch check
(`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the
dev server's inline hydration script, so prefetch in production was a
no-op.
## Tests
- `tests/pages-data-url.test.ts` (new): 27 tests covering URL
construction and the client/server round trip.
- `tests/shims.test.ts` (+8): JSON-path navigation success, 404
hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling,
HTML fallback when no loader matches, dynamic param extraction,
malformed JSON hard-reload, prefetch JSON path, prefetch fallback.
- Ports from Next.js: `getDataHref` in
`.nextjs-ref/packages/next/src/client/page-loader.ts:154`,
`fetchNextData` in
`.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`,
`getAssetPathFromRoute` in
`.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`.
Verified: 1393 vitest tests pass across shims, pages-router,
link/navigation, prefetch, data-route, data-url, entry-templates, and
routing.
Closes #1330 follow-up.
* fix(pages-router): drop unused export on PagesPatternMatch
Knip flagged the exported `PagesPatternMatch` type as unused. It is only
referenced as the return type of `matchPagesPattern` in the same file, so
narrow it to a local type.
* fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav
bigbonk review found that navigateClientData was synthesising
__NEXT_DATA__.query from dynamic route params only, missing the URL search
params. Code reading window.__NEXT_DATA__.query directly (a public Next.js
surface) would see incomplete data after a JSON navigation, even though
useRouter().query happened to still work because getPathnameAndQuery()
re-reads window.location.search.
Parse target.search and merge it into query alongside the route params,
with route params winning on key collision — same merge order as
getPathnameAndQuery() and Next.js itself.
Also fix a misleading comment in pages-client-entry.ts: the patterns list
is already sorted by pagesRouter()/compareRoutes (static → dynamic →
catch-all), not by the router at runtime.
* test(pages-router): e2e regression guard for JSON nav path
Adds a Playwright assertion that a Link click on a hydrated Pages Router
app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json
and does NOT trigger a full HTML reload. If the build pipeline ever stops
exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other
navigation test still passes — they all assert the URL changed and the new
page rendered, neither of which fails when navigateClient silently falls
back to the HTML path. This test fails loudly in that scenario.
* test(pages-router): use hydration timestamp instead of resource-type sniffing
The original assertion 'no document-resource request to /ssr' caught false
positives: <link rel="prefetch" as="document"> can fire on initial paint
for unrelated reasons, and Chromium does not always classify prefetch hints
in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the
click — a document reload would reset it, an in-place re-render preserves it.
Combined with the data-URL network assertion, this remains a tight guard on
the JSON nav path without the flake.
* test(pages-router): use window sentinel instead of timestamp comparison
Timestamps are timing-related signals; swap to a window-scoped sentinel
that a document reload would wipe and an in-place re-render preserves.
Same guarantee, no flake.
* fix(pages-router): handle _next/data requests in Cloudflare Workers example
The Node prod-server normalizes /_next/data/<buildId>/<page>.json before
middleware and flags it as a data request so renderPage emits the JSON
envelope. The Cloudflare worker example never did — data requests hit
matchPageRoute which can't match /_next/data/... and returned 404, so
navigateClientData saw the 404 and triggered a hard navigation. The
client-side JSON nav path was effectively dead on Workers.
Mirror the prod-server normalization in the worker: detect the data path
before middleware, validate the buildId, strip it to the page path, and
pass { isDataReq: true } through to renderPage.
Export ./server/pages-data-route from the vinext package so user-written
workers can import the helpers.
* Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example"
This reverts commit 719575b9ae.
* fix(pages-router): normalize _next/data requests inside the server entry
The Node prod-server normalized /_next/data/<buildId>/<page>.json before
middleware so renderPage could emit the JSON envelope. Cloudflare Workers
go through a user-written worker entry that doesn't (and shouldn't) know
about the data endpoint protocol, so data requests were reaching
matchPageRoute as /_next/data/... URLs, returning 404, and the client
fell back to a hard navigation — defeating the whole JSON-path
optimisation on Workers.
Move the detection into the generated server entry so it works for any
caller. runMiddleware now normalizes the URL for middleware matcher and
signals via result.rewriteUrl, and renderPage auto-detects data requests
from request.url so the JSON envelope fires whether or not middleware
rewrote the URL. Mismatched buildId short-circuits to a JSON 404 in
both paths so stale clients hard-navigate onto the new build.
* chore: re-trigger CI
* refactor(pages-router): share data-nav target resolver between Link and Router
Extract `resolvePagesDataNavigationTarget` + `prefetchPagesData` into
`shims/internal/pages-data-target.ts` so the Link prefetch path and the
Router (`navigateClient` + `Router.prefetch`) share one decision helper
instead of maintaining two structurally identical copies.
Also strip the locale prefix before matching `__VINEXT_PAGE_PATTERNS__`
so locale transitions (e.g. `/fr/about`) hit the JSON data fast path
instead of falling through to the slower HTML round-trip — route
patterns are locale-unaware (`/about`), but the browser URL for a
locale-prefixed page includes the locale. The data URL itself keeps
the locale prefix because the server uses it to pick locale-specific
gSSP data.
When the data path is used and the URL implies a different locale than
the current `__NEXT_DATA__.locale`, refresh the locale fields on the
synthesised `__NEXT_DATA__` (the JSON envelope itself only carries
`pageProps`, so the locale must be derived from the URL). Without this,
`useRouter().locale` stays stale after a client-side locale transition.
* chore: re-trigger CI
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
import { describe, it, expect } from "vite-plus/test";
|
|
import {
|
|
buildPagesDataPath,
|
|
buildPagesDataHref,
|
|
matchPagesPattern,
|
|
} from "../packages/vinext/src/shims/internal/pages-data-url.js";
|
|
import { parseNextDataPathname } from "../packages/vinext/src/server/pages-data-route.js";
|
|
|
|
describe("pages-data-url (client-side)", () => {
|
|
const BUILD_ID = "abc123";
|
|
|
|
describe("buildPagesDataPath", () => {
|
|
it("encodes the root pathname as /index.json", () => {
|
|
expect(buildPagesDataPath(BUILD_ID, "/")).toBe(`/_next/data/${BUILD_ID}/index.json`);
|
|
});
|
|
|
|
it("encodes a flat path", () => {
|
|
expect(buildPagesDataPath(BUILD_ID, "/about")).toBe(`/_next/data/${BUILD_ID}/about.json`);
|
|
});
|
|
|
|
it("encodes a nested path", () => {
|
|
expect(buildPagesDataPath(BUILD_ID, "/blog/foo")).toBe(
|
|
`/_next/data/${BUILD_ID}/blog/foo.json`,
|
|
);
|
|
});
|
|
|
|
it("disambiguates an explicit /index page from the root", () => {
|
|
// Next.js denormalisation: `pages/index/index.tsx` would resolve to
|
|
// `/index`. The data URL must distinguish this from `/`, so the asset
|
|
// path becomes `/index/index.json`.
|
|
expect(buildPagesDataPath(BUILD_ID, "/index")).toBe(
|
|
`/_next/data/${BUILD_ID}/index/index.json`,
|
|
);
|
|
});
|
|
|
|
it("disambiguates an /index/foo page from /foo", () => {
|
|
// Matches Next.js' getAssetPathFromRoute: any path starting with
|
|
// `/index` (`/index/...` or exactly `/index`) gets a second `/index`
|
|
// prepended so it round-trips through the data URL parser.
|
|
expect(buildPagesDataPath(BUILD_ID, "/index/foo")).toBe(
|
|
`/_next/data/${BUILD_ID}/index/index/foo.json`,
|
|
);
|
|
});
|
|
|
|
it("strips trailing slash before appending .json", () => {
|
|
expect(buildPagesDataPath(BUILD_ID, "/about/")).toBe(`/_next/data/${BUILD_ID}/about.json`);
|
|
});
|
|
|
|
it("preserves locale prefix (callers are responsible for adding it)", () => {
|
|
expect(buildPagesDataPath(BUILD_ID, "/en/about")).toBe(
|
|
`/_next/data/${BUILD_ID}/en/about.json`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildPagesDataHref", () => {
|
|
it("includes the basePath prefix", () => {
|
|
expect(buildPagesDataHref("/app", BUILD_ID, "/about", "")).toBe(
|
|
`/app/_next/data/${BUILD_ID}/about.json`,
|
|
);
|
|
});
|
|
|
|
it("omits the basePath prefix when empty", () => {
|
|
expect(buildPagesDataHref("", BUILD_ID, "/about", "")).toBe(
|
|
`/_next/data/${BUILD_ID}/about.json`,
|
|
);
|
|
});
|
|
|
|
it("appends the search string verbatim", () => {
|
|
expect(buildPagesDataHref("", BUILD_ID, "/about", "?a=1&b=2")).toBe(
|
|
`/_next/data/${BUILD_ID}/about.json?a=1&b=2`,
|
|
);
|
|
});
|
|
|
|
it("appends the search string for the root page", () => {
|
|
expect(buildPagesDataHref("", BUILD_ID, "/", "?ref=home")).toBe(
|
|
`/_next/data/${BUILD_ID}/index.json?ref=home`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("round-trip with parseNextDataPathname", () => {
|
|
// The server's parseNextDataPathname must agree with the client's
|
|
// buildPagesDataPath for every shape the client can produce. This is the
|
|
// wire-format contract between client navigation and the data endpoint.
|
|
const cases = ["/", "/about", "/blog/foo", "/en/about", "/blog/post-1/comments"];
|
|
|
|
for (const path of cases) {
|
|
it(`parses ${path} back to itself`, () => {
|
|
const built = buildPagesDataPath(BUILD_ID, path);
|
|
const parsed = parseNextDataPathname(built, BUILD_ID);
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed?.pagePathname).toBe(path);
|
|
});
|
|
}
|
|
|
|
it("round-trips /index by encoding as /index/index.json", () => {
|
|
const built = buildPagesDataPath(BUILD_ID, "/index");
|
|
expect(built).toBe(`/_next/data/${BUILD_ID}/index/index.json`);
|
|
// Parser denormalises trailing `/index` back to the parent directory
|
|
// (the `endsWith("/index")` branch in pages-data-route.ts).
|
|
const parsed = parseNextDataPathname(built, BUILD_ID);
|
|
expect(parsed?.pagePathname).toBe("/index");
|
|
});
|
|
|
|
it("round-trips /index/foo through both helpers", () => {
|
|
const built = buildPagesDataPath(BUILD_ID, "/index/foo");
|
|
expect(built).toBe(`/_next/data/${BUILD_ID}/index/index/foo.json`);
|
|
const parsed = parseNextDataPathname(built, BUILD_ID);
|
|
expect(parsed?.pagePathname).toBe("/index/foo");
|
|
});
|
|
});
|
|
|
|
describe("matchPagesPattern", () => {
|
|
it("matches a literal pattern", () => {
|
|
expect(matchPagesPattern("/about", ["/about", "/contact"])).toEqual({
|
|
pattern: "/about",
|
|
params: {},
|
|
});
|
|
});
|
|
|
|
it("matches a single dynamic segment", () => {
|
|
expect(matchPagesPattern("/posts/42", ["/posts/[id]"])).toEqual({
|
|
pattern: "/posts/[id]",
|
|
params: { id: "42" },
|
|
});
|
|
});
|
|
|
|
it("matches a catch-all segment", () => {
|
|
expect(matchPagesPattern("/docs/intro/getting-started", ["/docs/[...slug]"])).toEqual({
|
|
pattern: "/docs/[...slug]",
|
|
params: { slug: ["intro", "getting-started"] },
|
|
});
|
|
});
|
|
|
|
it("matches an optional catch-all with no segments at the root", () => {
|
|
expect(matchPagesPattern("/shop", ["/shop/[[...path]]"])).toEqual({
|
|
pattern: "/shop/[[...path]]",
|
|
params: {},
|
|
});
|
|
});
|
|
|
|
it("returns null when no pattern matches", () => {
|
|
expect(matchPagesPattern("/unknown", ["/about", "/posts/[id]"])).toBeNull();
|
|
});
|
|
|
|
it("prefers earlier patterns when multiple could match", () => {
|
|
// Patterns array is ordered by the caller; this test confirms the
|
|
// function honours that order (does not re-sort internally).
|
|
expect(matchPagesPattern("/posts/42", ["/posts/[id]", "/posts/[...rest]"])).toEqual({
|
|
pattern: "/posts/[id]",
|
|
params: { id: "42" },
|
|
});
|
|
});
|
|
|
|
it("matches the root path", () => {
|
|
expect(matchPagesPattern("/", ["/"])).toEqual({ pattern: "/", params: {} });
|
|
});
|
|
});
|
|
});
|