Files
cloudflare__vinext/tests/app-static-siblings.test.ts
T
James Anderson 88b1152eb2 fix(app-router): include static-sibling info in SSR response (#1525) (#1686)
Next.js's loader-tree dynamic-segment tuple includes a `staticSiblings`
list (the static sibling segment names at the dynamic URL level) so the
client router can decide whether a cached prefetch of a dynamic route
can be reused when navigating to a static sibling URL. vinext was not
emitting this metadata, so the static-siblings e2e suite failed all four
substring assertions against the RSC payload.

This change:

- Adds `computeAppRouteStaticSiblings(allRoutes, matchedRoute)` to the
  app route graph. Ported from Next.js's `getStaticSiblingSegments` in
  the next-app-loader. For each dynamic URL level in the matched route,
  it collects the names of routes whose corresponding segment is
  static and whose preceding URL prefix matches literally.
- Pre-computes the result per route at entry-generator time
  (`buildRouteEntries` in app-rsc-manifest) so the runtime carries a
  static list instead of recomputing for every request.
- Threads it through `AppPageRouteWiringRoute` and emits it on the
  wire as a new `__staticSiblings` metadata key when non-empty. The
  Flight serializer already spreads the elements record into the
  outgoing payload, so the key lands on the RSC response unchanged.

The new `tests/app-static-siblings.test.ts` covers the helper's
sibling-collection rules (cross-route-group, same-directory, deeply-
nested non-leak, dedup) and asserts `buildAppPageElements` emits
`__staticSiblings` on dynamic routes and omits it when empty — matching
the four sub-cases of Next.js's static-siblings e2e suite.

Ported from Next.js:
test/e2e/app-dir/static-siblings/static-siblings.test.ts

Refs cloudflare/vinext#1328
2026-05-29 10:05:43 +01:00

143 lines
5.7 KiB
TypeScript

import { createElement } from "react";
import { describe, expect, it } from "vite-plus/test";
import { computeAppRouteStaticSiblings } from "../packages/vinext/src/routing/app-router.js";
import { APP_STATIC_SIBLINGS_KEY } from "../packages/vinext/src/server/app-elements.js";
import { buildAppPageElements } from "../packages/vinext/src/server/app-page-route-wiring.js";
// Ported from Next.js: test/e2e/app-dir/static-siblings/static-siblings.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/static-siblings/static-siblings.test.ts
//
// Issue: https://github.com/cloudflare/vinext/issues/1525
describe("App Router static-sibling info on the server response", () => {
describe("computeAppRouteStaticSiblings", () => {
it("returns the names of static sibling routes at the dynamic URL level", () => {
// Mirrors Next.js's filesystem layout used in the static-siblings e2e
// fixture: cross-route-group siblings where the static page (`sale`) and
// the dynamic page (`[id]`) live under different route groups but share
// the URL prefix `/products`.
const routes = [
{ patternParts: ["products", "sale"] }, // /products/sale
{ patternParts: ["products", ":id"] }, // /products/[id]
];
expect(computeAppRouteStaticSiblings(routes, routes[1])).toEqual(["sale"]);
});
it("collects same-directory sibling segments alongside the dynamic segment", () => {
// /items/featured + /items/[id]
const routes = [{ patternParts: ["items", "featured"] }, { patternParts: ["items", ":id"] }];
expect(computeAppRouteStaticSiblings(routes, routes[1])).toEqual(["featured"]);
});
it("only collects siblings at the same level — does not leak nested segments", () => {
// /categories/electronics/computers/laptops + /categories/[slug] must
// collect only `electronics` for [slug]. Nested `computers` and
// `laptops` are below the sibling level and must not be reported.
const routes = [
{ patternParts: ["categories", "electronics", "computers", "laptops"] },
{ patternParts: ["categories", ":slug"] },
];
expect(computeAppRouteStaticSiblings(routes, routes[1])).toEqual(["electronics"]);
});
it("returns an empty list when no static siblings exist", () => {
const routes = [{ patternParts: ["only", ":id"] }, { patternParts: ["unrelated", "page"] }];
expect(computeAppRouteStaticSiblings(routes, routes[0])).toEqual([]);
});
it("returns an empty list for fully-static routes", () => {
const routes = [{ patternParts: ["a", "b"] }, { patternParts: ["a", "c"] }];
expect(computeAppRouteStaticSiblings(routes, routes[0])).toEqual([]);
});
it("deduplicates sibling names that appear in multiple routes", () => {
const routes = [
{ patternParts: ["x", "sale"] },
{ patternParts: ["x", "sale", "deeper"] },
{ patternParts: ["x", ":id"] },
];
expect(computeAppRouteStaticSiblings(routes, routes[2])).toEqual(["sale"]);
});
});
describe("buildAppPageElements", () => {
it("emits __staticSiblings in the elements payload when the route has static siblings", () => {
// The static-siblings e2e test asserts that the substring `"sale"` is
// present in the RSC payload for a dynamic route that has `sale` as a
// static sibling. The wire serializer spreads element keys directly into
// the Flight payload, so emitting an `__staticSiblings: ["sale"]` entry
// here is sufficient for the substring to land on the wire.
function PageProbe() {
return createElement("main", null, "Page");
}
function RootLayout(props: Record<string, unknown>) {
return createElement("div", null, props.children as never);
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { id: "123" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, null],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["products", "[id]"],
slots: {},
staticSiblings: ["sale"],
templateTreePositions: [],
templates: [],
},
routePath: "/products/123",
rootNotFoundModule: null,
});
expect(elements[APP_STATIC_SIBLINGS_KEY]).toEqual(["sale"]);
});
it("omits __staticSiblings when the route has no siblings", () => {
function PageProbe() {
return createElement("main", null, "Page");
}
function RootLayout(props: Record<string, unknown>) {
return createElement("div", null, props.children as never);
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["about"],
slots: {},
staticSiblings: [],
templateTreePositions: [],
templates: [],
},
routePath: "/about",
rootNotFoundModule: null,
});
expect(elements[APP_STATIC_SIBLINGS_KEY]).toBeUndefined();
});
});
});