Files
cloudflare__vinext/tests/app-elements.test.ts
T
Nathan Nguyen 9791a63381 feat(app-router): emit per-layout flags in the RSC payload [2/6] (#839)
* feat(app-router): emit per-layout flags in the RSC payload

Wire runtime layout classification through renderAppPageLifecycle and
attach the resulting flags as __layoutFlags in the outgoing RSC payload
via buildOutgoingAppPayload. Payload-shape helpers (withLayoutFlags,
isAppElementsRecord, buildOutgoingAppPayload, AppOutgoingElements) live
alongside the existing readAppElementsMetadata so the write and read
boundaries sit next to each other.

* fix(app-elements): widen isAppElementsRecord narrowing and drop redundant clone

- isAppElementsRecord narrowed to Record<string, ReactNode>, but the
  outgoing payload legitimately carries heterogeneous values (ReactNode
  for the rendered tree plus LayoutFlags under __layoutFlags). Widen the
  predicate to Readonly<Record<string, unknown>> so the return type
  matches what the runtime check actually proves.

- buildOutgoingAppPayload spread input.element into a new object before
  passing it to withLayoutFlags, which immediately spreads again. Drop
  the outer copy; withLayoutFlags already guarantees immutability.

* fix(app-router): clear leaked dynamic usage on layout probe errors
2026-04-15 09:16:06 +01:00

293 lines
10 KiB
TypeScript

import React from "react";
import { describe, expect, it } from "vite-plus/test";
import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js";
import {
APP_INTERCEPTION_CONTEXT_KEY,
APP_LAYOUT_FLAGS_KEY,
APP_ROOT_LAYOUT_KEY,
APP_ROUTE_KEY,
APP_UNMATCHED_SLOT_WIRE_VALUE,
buildOutgoingAppPayload,
createAppPayloadCacheKey,
createAppPayloadRouteId,
isAppElementsRecord,
normalizeAppElements,
readAppElementsMetadata,
resolveVisitedResponseInterceptionContext,
withLayoutFlags,
} from "../packages/vinext/src/server/app-elements.js";
describe("app elements payload helpers", () => {
it("normalizes the unmatched-slot wire marker to UNMATCHED_SLOT for slot entries", () => {
const normalized = normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
"page:/dashboard": React.createElement("main", null, "dashboard"),
"slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE,
});
expect(normalized["slot:modal:/"]).toBe(UNMATCHED_SLOT);
expect(normalized["page:/dashboard"]).not.toBe(UNMATCHED_SLOT);
});
it("does not rewrite the unmatched-slot wire marker for non-slot entries", () => {
const normalized = normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
"page:/dashboard": APP_UNMATCHED_SLOT_WIRE_VALUE,
});
expect(normalized["page:/dashboard"]).toBe(APP_UNMATCHED_SLOT_WIRE_VALUE);
});
it("reads route metadata from the normalized payload", () => {
const metadata = readAppElementsMetadata(
normalizeAppElements({
[APP_INTERCEPTION_CONTEXT_KEY]: "/feed",
[APP_ROOT_LAYOUT_KEY]: "/(dashboard)",
[APP_ROUTE_KEY]: "route:/dashboard",
"route:/dashboard": React.createElement("div", null, "route"),
}),
);
expect(metadata.routeId).toBe("route:/dashboard");
expect(metadata.interceptionContext).toBe("/feed");
expect(metadata.rootLayoutTreePath).toBe("/(dashboard)");
});
it("defaults missing interception context metadata to null", () => {
const metadata = readAppElementsMetadata(
normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
"route:/dashboard": React.createElement("div", null, "route"),
}),
);
expect(metadata.interceptionContext).toBeNull();
});
it("encodes intercepted route ids and cache keys with a NUL separator", () => {
expect(createAppPayloadRouteId("/photos/42", null)).toBe("route:/photos/42");
expect(createAppPayloadRouteId("/photos/42", "/feed")).toBe("route:/photos/42\0/feed");
expect(createAppPayloadCacheKey("/photos/42.rsc", null)).toBe("/photos/42.rsc");
expect(createAppPayloadCacheKey("/photos/42.rsc", "/feed")).toBe("/photos/42.rsc\0/feed");
});
it("preserves the request cache context when a direct-route payload omits it", () => {
expect(resolveVisitedResponseInterceptionContext("/feed", null)).toBe("/feed");
expect(resolveVisitedResponseInterceptionContext("/feed", "/feed")).toBe("/feed");
expect(resolveVisitedResponseInterceptionContext("/feed", "/gallery")).toBe("/gallery");
expect(resolveVisitedResponseInterceptionContext(null, null)).toBeNull();
});
it("rejects payloads with a missing __route key", () => {
expect(() =>
readAppElementsMetadata(
normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
}),
),
).toThrow("[vinext] Missing __route string in App Router payload");
});
it("rejects payloads with an invalid __rootLayout value", () => {
expect(() =>
readAppElementsMetadata(
normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: 123,
[APP_ROUTE_KEY]: "route:/dashboard",
}),
),
).toThrow("[vinext] Invalid __rootLayout in App Router payload: expected string or null");
});
it("rejects payloads with a missing __rootLayout key", () => {
expect(() =>
readAppElementsMetadata(
normalizeAppElements({
[APP_ROUTE_KEY]: "route:/dashboard",
}),
),
).toThrow("[vinext] Missing __rootLayout key in App Router payload");
});
it("rejects payloads with an invalid __interceptionContext value", () => {
expect(() =>
readAppElementsMetadata(
normalizeAppElements({
[APP_INTERCEPTION_CONTEXT_KEY]: 123,
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
}),
),
).toThrow("[vinext] Invalid __interceptionContext in App Router payload");
});
it("reads layoutFlags from payload metadata", () => {
// Layout flags are set directly on the elements object (not via
// normalizeAppElements which expects AppWireElementValue types).
const elements = {
...normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/blog",
"page:/blog": React.createElement("div", null, "blog"),
}),
[APP_LAYOUT_FLAGS_KEY]: { "layout:/": "s", "layout:/blog": "d" },
};
const metadata = readAppElementsMetadata(elements);
expect(metadata.layoutFlags).toEqual({ "layout:/": "s", "layout:/blog": "d" });
});
it("defaults missing layoutFlags to empty object (backward compat)", () => {
const metadata = readAppElementsMetadata(
normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
"route:/dashboard": React.createElement("div", null, "route"),
}),
);
expect(metadata.layoutFlags).toEqual({});
});
});
describe("isAppElementsRecord", () => {
it("returns true for a plain record", () => {
expect(isAppElementsRecord({ "page:/": "x" })).toBe(true);
});
it("returns false for a React element", () => {
expect(isAppElementsRecord(React.createElement("div", null, "x"))).toBe(false);
});
it("returns false for null", () => {
expect(isAppElementsRecord(null)).toBe(false);
});
it("returns false for an array", () => {
expect(isAppElementsRecord([])).toBe(false);
});
it("returns false for a string", () => {
expect(isAppElementsRecord("string")).toBe(false);
});
it("returns false for undefined", () => {
expect(isAppElementsRecord(undefined)).toBe(false);
});
});
describe("withLayoutFlags", () => {
it("attaches the __layoutFlags key with the supplied value", () => {
const input = { "page:/": "page" };
const result = withLayoutFlags(input, { "layout:/": "s" });
expect(result[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" });
});
it("does not mutate the input", () => {
const input: Record<string, unknown> = { "page:/": "page", "layout:/": "layout" };
const snapshot = structuredClone(input);
const result = withLayoutFlags(input, { "layout:/": "d" });
expect(result).not.toBe(input);
expect(input).toEqual(snapshot);
expect(Object.keys(input)).toEqual(Object.keys(snapshot));
expect(APP_LAYOUT_FLAGS_KEY in input).toBe(false);
});
it("preserves other keys on the returned object", () => {
const input = { "page:/blog": "page", "layout:/": "layout" };
const result = withLayoutFlags(input, { "layout:/": "s" });
expect(result["page:/blog"]).toBe("page");
expect(result["layout:/"]).toBe("layout");
});
it("returns a new object with a different identity", () => {
const input = { "page:/": "page" };
const result = withLayoutFlags(input, {});
expect(result).not.toBe(input);
});
});
describe("buildOutgoingAppPayload", () => {
it("returns a non-record element unchanged (same identity)", () => {
const element = React.createElement("div", null, "page");
const result = buildOutgoingAppPayload({
element,
layoutFlags: { "layout:/": "s" },
});
expect(result).toBe(element);
});
it("returns a new object for a record element (different identity)", () => {
const element = { "page:/": "page" };
const result = buildOutgoingAppPayload({
element,
layoutFlags: {},
});
expect(result).not.toBe(element);
});
it("does not mutate the input record", () => {
const element: Record<string, React.ReactNode> = {
"layout:/": "root-layout",
"layout:/blog": "blog-layout",
"page:/blog": "blog-page",
};
const snapshot = structuredClone(element);
const result = buildOutgoingAppPayload({
element,
layoutFlags: { "layout:/": "s", "layout:/blog": "d" },
});
expect(result).not.toBe(element);
expect(element).toEqual(snapshot);
expect(Object.keys(element)).toEqual(Object.keys(snapshot));
expect(APP_LAYOUT_FLAGS_KEY in element).toBe(false);
});
it("attaches __layoutFlags on the returned record", () => {
const result = buildOutgoingAppPayload({
element: { "page:/": "page" },
layoutFlags: { "layout:/": "s" },
});
expect(isAppElementsRecord(result)).toBe(true);
if (isAppElementsRecord(result)) {
expect(result[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" });
}
});
it("returns canonical record keys regardless of any upstream skip intent", () => {
const result = buildOutgoingAppPayload({
element: { "layout:/": "root-layout", "page:/": "page" },
layoutFlags: { "layout:/": "s" },
});
expect(isAppElementsRecord(result)).toBe(true);
if (isAppElementsRecord(result)) {
expect(result["layout:/"]).toBe("root-layout");
expect(result["page:/"]).toBe("page");
}
});
it("preserves non-layout metadata keys", () => {
const result = buildOutgoingAppPayload({
element: {
[APP_ROUTE_KEY]: "route:/blog",
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_INTERCEPTION_CONTEXT_KEY]: null,
"layout:/": "root-layout",
"page:/blog": "blog-page",
},
layoutFlags: { "layout:/": "s" },
});
expect(isAppElementsRecord(result)).toBe(true);
if (isAppElementsRecord(result)) {
expect(result[APP_ROUTE_KEY]).toBe("route:/blog");
expect(result[APP_ROOT_LAYOUT_KEY]).toBe("/");
expect(result[APP_INTERCEPTION_CONTEXT_KEY]).toBeNull();
expect(result["page:/blog"]).toBe("blog-page");
expect(result["layout:/"]).toBe("root-layout");
}
});
});