mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e81820279e
* fix: preserve console output for caught app errors in dev * address bonk review comments on PR 862 --------- Co-authored-by: James <james@eli.cx>
627 lines
21 KiB
TypeScript
627 lines
21 KiB
TypeScript
import React from "react";
|
|
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import { devOnCaughtError } from "../packages/vinext/src/server/app-browser-error.js";
|
|
import {
|
|
APP_INTERCEPTION_CONTEXT_KEY,
|
|
APP_ROOT_LAYOUT_KEY,
|
|
APP_ROUTE_KEY,
|
|
UNMATCHED_SLOT,
|
|
getMountedSlotIds,
|
|
getMountedSlotIdsHeader,
|
|
normalizeAppElements,
|
|
type AppElements,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js";
|
|
import {
|
|
createHistoryStateWithPreviousNextUrl,
|
|
createPendingNavigationCommit,
|
|
readHistoryStatePreviousNextUrl,
|
|
resolveAndClassifyNavigationCommit,
|
|
resolveInterceptionContextFromPreviousNextUrl,
|
|
resolveServerActionRequestState,
|
|
routerReducer,
|
|
resolvePendingNavigationCommitDisposition,
|
|
shouldHardNavigate,
|
|
type AppRouterState,
|
|
} from "../packages/vinext/src/server/app-browser-state.js";
|
|
|
|
function createResolvedElements(
|
|
routeId: string,
|
|
rootLayoutTreePath: string | null,
|
|
interceptionContext: string | null = null,
|
|
extraEntries: Record<string, unknown> = {},
|
|
) {
|
|
return normalizeAppElements({
|
|
[APP_INTERCEPTION_CONTEXT_KEY]: interceptionContext,
|
|
[APP_ROUTE_KEY]: routeId,
|
|
[APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath,
|
|
...extraEntries,
|
|
});
|
|
}
|
|
|
|
function createState(overrides: Partial<AppRouterState> = {}): AppRouterState {
|
|
return {
|
|
elements: createResolvedElements("route:/initial", "/"),
|
|
layoutFlags: {},
|
|
navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}),
|
|
renderId: 0,
|
|
interceptionContext: null,
|
|
previousNextUrl: null,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/initial",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("app browser entry state helpers", () => {
|
|
it("requires renderId when creating pending commits", () => {
|
|
// @ts-expect-error renderId is required to avoid duplicate commit ids.
|
|
void createPendingNavigationCommit({
|
|
currentState: createState(),
|
|
nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")),
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
type: "navigate",
|
|
});
|
|
});
|
|
|
|
it("merges elements on navigate", async () => {
|
|
const previousElements = createResolvedElements("route:/initial", "/", null, {
|
|
"layout:/": React.createElement("div", null, "layout"),
|
|
});
|
|
const nextElements = createResolvedElements("route:/next", "/", null, {
|
|
"page:/next": React.createElement("main", null, "next"),
|
|
});
|
|
|
|
const nextState = routerReducer(
|
|
createState({
|
|
elements: previousElements,
|
|
}),
|
|
{
|
|
elements: nextElements,
|
|
interceptionContext: null,
|
|
layoutFlags: {},
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/next",
|
|
type: "navigate",
|
|
},
|
|
);
|
|
|
|
expect(nextState.routeId).toBe("route:/next");
|
|
expect(nextState.interceptionContext).toBeNull();
|
|
expect(nextState.previousNextUrl).toBeNull();
|
|
expect(nextState.rootLayoutTreePath).toBe("/");
|
|
expect(nextState.elements).toMatchObject({
|
|
"layout:/": expect.anything(),
|
|
"page:/next": expect.anything(),
|
|
});
|
|
});
|
|
|
|
it("replaces elements on replace", () => {
|
|
const nextElements = createResolvedElements("route:/next", "/", null, {
|
|
"page:/next": React.createElement("main", null, "next"),
|
|
});
|
|
|
|
const nextState = routerReducer(createState(), {
|
|
elements: nextElements,
|
|
interceptionContext: null,
|
|
layoutFlags: {},
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/next",
|
|
type: "replace",
|
|
});
|
|
|
|
expect(nextState.elements).toBe(nextElements);
|
|
expect(nextState.interceptionContext).toBeNull();
|
|
expect(nextState.previousNextUrl).toBeNull();
|
|
expect(nextState.elements).toMatchObject({
|
|
"page:/next": expect.anything(),
|
|
});
|
|
});
|
|
|
|
it("carries interception context through pending navigation commits", async () => {
|
|
const pending = await createPendingNavigationCommit({
|
|
currentState: createState(),
|
|
nextElements: Promise.resolve(
|
|
createResolvedElements("route:/photos/42\0/feed", "/", "/feed", {
|
|
"page:/photos/42": React.createElement("main", null, "photo"),
|
|
}),
|
|
),
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: "/feed",
|
|
renderId: 1,
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(pending.routeId).toBe("route:/photos/42\0/feed");
|
|
expect(pending.interceptionContext).toBe("/feed");
|
|
expect(pending.previousNextUrl).toBe("/feed");
|
|
expect(pending.action.interceptionContext).toBe("/feed");
|
|
expect(pending.action.previousNextUrl).toBe("/feed");
|
|
});
|
|
|
|
it("clears previousNextUrl when traversing to a non-intercepted entry", async () => {
|
|
// Traversing back from an intercepted modal (/photos/42 from /feed) to
|
|
// /feed itself. The traverse branch reads null from /feed's history state
|
|
// and passes previousNextUrl: null explicitly — meaning "not intercepted".
|
|
// This must not inherit the current state's stale "/feed" value.
|
|
const interceptedState = createState({
|
|
interceptionContext: "/feed",
|
|
previousNextUrl: "/feed",
|
|
routeId: "route:/photos/42\0/feed",
|
|
});
|
|
|
|
const pending = await createPendingNavigationCommit({
|
|
currentState: interceptedState,
|
|
nextElements: Promise.resolve(createResolvedElements("route:/feed", "/")),
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 2,
|
|
type: "traverse",
|
|
});
|
|
|
|
expect(pending.previousNextUrl).toBeNull();
|
|
expect(pending.action.previousNextUrl).toBeNull();
|
|
});
|
|
|
|
it("hard navigates instead of merging when the root layout changes", async () => {
|
|
const currentState = createState({
|
|
rootLayoutTreePath: "/(marketing)",
|
|
});
|
|
const pending = await createPendingNavigationCommit({
|
|
currentState,
|
|
nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")),
|
|
navigationSnapshot: currentState.navigationSnapshot,
|
|
renderId: 1,
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(
|
|
resolvePendingNavigationCommitDisposition({
|
|
activeNavigationId: 3,
|
|
currentRootLayoutTreePath: currentState.rootLayoutTreePath,
|
|
nextRootLayoutTreePath: pending.rootLayoutTreePath,
|
|
startedNavigationId: 3,
|
|
}),
|
|
).toBe("hard-navigate");
|
|
});
|
|
|
|
it("defers commit classification until the payload has resolved", async () => {
|
|
let resolveElements: ((value: AppElements) => void) | undefined;
|
|
const nextElements = new Promise<AppElements>((resolve) => {
|
|
resolveElements = resolve;
|
|
});
|
|
let resolved = false;
|
|
const pending = createPendingNavigationCommit({
|
|
currentState: createState(),
|
|
nextElements,
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
renderId: 1,
|
|
type: "navigate",
|
|
}).then((result) => {
|
|
resolved = true;
|
|
return result;
|
|
});
|
|
|
|
expect(resolved).toBe(false);
|
|
|
|
if (!resolveElements) {
|
|
throw new Error("Expected deferred elements resolver");
|
|
}
|
|
|
|
resolveElements(
|
|
normalizeAppElements({
|
|
[APP_ROUTE_KEY]: "route:/dashboard",
|
|
[APP_ROOT_LAYOUT_KEY]: "/",
|
|
"page:/dashboard": React.createElement("main", null, "dashboard"),
|
|
}),
|
|
);
|
|
|
|
const result = await pending;
|
|
|
|
expect(resolved).toBe(true);
|
|
expect(result.routeId).toBe("route:/dashboard");
|
|
});
|
|
|
|
it("skips a pending commit when a newer navigation has become active", async () => {
|
|
const currentState = createState();
|
|
const pending = await createPendingNavigationCommit({
|
|
currentState,
|
|
nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")),
|
|
navigationSnapshot: currentState.navigationSnapshot,
|
|
renderId: 1,
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(
|
|
resolvePendingNavigationCommitDisposition({
|
|
activeNavigationId: 5,
|
|
currentRootLayoutTreePath: currentState.rootLayoutTreePath,
|
|
nextRootLayoutTreePath: pending.rootLayoutTreePath,
|
|
startedNavigationId: 4,
|
|
}),
|
|
).toBe("skip");
|
|
});
|
|
|
|
it("builds a merge commit for refresh and server-action payloads", async () => {
|
|
const refreshCommit = await createPendingNavigationCommit({
|
|
currentState: createState(),
|
|
nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")),
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: "/feed",
|
|
renderId: 1,
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(refreshCommit.action.type).toBe("navigate");
|
|
expect(refreshCommit.routeId).toBe("route:/dashboard");
|
|
expect(refreshCommit.rootLayoutTreePath).toBe("/");
|
|
expect(refreshCommit.previousNextUrl).toBe("/feed");
|
|
});
|
|
|
|
it("merges layoutFlags on navigate", () => {
|
|
const nextState = routerReducer(
|
|
createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }),
|
|
{
|
|
elements: createResolvedElements("route:/next", "/"),
|
|
interceptionContext: null,
|
|
layoutFlags: { "layout:/": "s", "layout:/blog": "d" },
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/next",
|
|
type: "navigate",
|
|
},
|
|
);
|
|
|
|
// Navigate merges: old flags preserved, new flags override
|
|
expect(nextState.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/old": "d",
|
|
"layout:/blog": "d",
|
|
});
|
|
});
|
|
|
|
it("replaces layoutFlags on replace", () => {
|
|
const nextState = routerReducer(
|
|
createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }),
|
|
{
|
|
elements: createResolvedElements("route:/next", "/"),
|
|
interceptionContext: null,
|
|
layoutFlags: { "layout:/": "d" },
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/next",
|
|
type: "replace",
|
|
},
|
|
);
|
|
|
|
// Replace: only new flags
|
|
expect(nextState.layoutFlags).toEqual({ "layout:/": "d" });
|
|
});
|
|
|
|
it("stores previousNextUrl on navigate actions", () => {
|
|
const nextState = routerReducer(createState(), {
|
|
elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"),
|
|
interceptionContext: "/feed",
|
|
layoutFlags: {},
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: "/feed",
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/photos/42\0/feed",
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(nextState.interceptionContext).toBe("/feed");
|
|
expect(nextState.previousNextUrl).toBe("/feed");
|
|
});
|
|
});
|
|
|
|
describe("app browser entry previousNextUrl helpers", () => {
|
|
it("stores previousNextUrl alongside existing history state", () => {
|
|
expect(
|
|
createHistoryStateWithPreviousNextUrl(
|
|
{
|
|
__vinext_scrollY: 120,
|
|
},
|
|
"/feed?tab=latest",
|
|
),
|
|
).toEqual({
|
|
__vinext_previousNextUrl: "/feed?tab=latest",
|
|
__vinext_scrollY: 120,
|
|
});
|
|
});
|
|
|
|
it("drops previousNextUrl when cleared", () => {
|
|
expect(
|
|
createHistoryStateWithPreviousNextUrl(
|
|
{
|
|
__vinext_previousNextUrl: "/feed",
|
|
__vinext_scrollY: 120,
|
|
},
|
|
null,
|
|
),
|
|
).toEqual({
|
|
__vinext_scrollY: 120,
|
|
});
|
|
});
|
|
|
|
it("reads previousNextUrl from history state", () => {
|
|
expect(
|
|
readHistoryStatePreviousNextUrl({
|
|
__vinext_previousNextUrl: "/feed?tab=latest",
|
|
}),
|
|
).toBe("/feed?tab=latest");
|
|
});
|
|
|
|
it("derives interception context from previousNextUrl pathname", () => {
|
|
expect(resolveInterceptionContextFromPreviousNextUrl("/feed?tab=latest")).toBe("/feed");
|
|
});
|
|
|
|
it("returns null when previousNextUrl is missing", () => {
|
|
expect(readHistoryStatePreviousNextUrl({})).toBeNull();
|
|
expect(resolveInterceptionContextFromPreviousNextUrl(null)).toBeNull();
|
|
});
|
|
|
|
it("classifies pending commits in one step for same-url payloads", async () => {
|
|
const currentState = createState({
|
|
rootLayoutTreePath: "/(marketing)",
|
|
});
|
|
|
|
const result = await resolveAndClassifyNavigationCommit({
|
|
activeNavigationId: 7,
|
|
currentState,
|
|
navigationSnapshot: currentState.navigationSnapshot,
|
|
nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")),
|
|
renderId: 3,
|
|
startedNavigationId: 7,
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(result.disposition).toBe("hard-navigate");
|
|
expect(result.pending.routeId).toBe("route:/dashboard");
|
|
expect(result.pending.action.renderId).toBe(3);
|
|
});
|
|
|
|
it("treats null root-layout identities as soft-navigation compatible", () => {
|
|
expect(shouldHardNavigate(null, null)).toBe(false);
|
|
expect(shouldHardNavigate(null, "/")).toBe(false);
|
|
expect(shouldHardNavigate("/", null)).toBe(false);
|
|
});
|
|
|
|
it("clears stale parallel slots on traverse", () => {
|
|
const state = createState({
|
|
elements: createResolvedElements("route:/feed", "/", null, {
|
|
"slot:modal:/feed": React.createElement("div", null, "modal"),
|
|
}),
|
|
});
|
|
const nextElements = createResolvedElements("route:/feed", "/");
|
|
|
|
const nextState = routerReducer(state, {
|
|
elements: nextElements,
|
|
interceptionContext: null,
|
|
layoutFlags: {},
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/feed",
|
|
type: "traverse",
|
|
});
|
|
|
|
expect(Object.hasOwn(nextState.elements, "slot:modal:/feed")).toBe(false);
|
|
});
|
|
|
|
it("preserves absent parallel slots on navigate", () => {
|
|
const state = createState({
|
|
elements: createResolvedElements("route:/feed", "/", null, {
|
|
"slot:modal:/feed": React.createElement("div", null, "modal"),
|
|
}),
|
|
});
|
|
const nextElements = createResolvedElements("route:/feed/comments", "/");
|
|
|
|
const nextState = routerReducer(state, {
|
|
elements: nextElements,
|
|
interceptionContext: null,
|
|
layoutFlags: {},
|
|
navigationSnapshot: createState().navigationSnapshot,
|
|
previousNextUrl: null,
|
|
renderId: 1,
|
|
rootLayoutTreePath: "/",
|
|
routeId: "route:/feed/comments",
|
|
type: "navigate",
|
|
});
|
|
|
|
expect(Object.hasOwn(nextState.elements, "slot:modal:/feed")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("devOnCaughtError (hydrateRoot dev handler)", () => {
|
|
it("logs caught errors to console.error", () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
const err = new Error("Maximum update depth exceeded");
|
|
devOnCaughtError(err, { componentStack: "\n at List\n at Apps" });
|
|
expect(consoleSpy).toHaveBeenCalled();
|
|
const loggedErrors = consoleSpy.mock.calls.map((args) => args[0]);
|
|
expect(loggedErrors).toContain(err);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("includes the React component stack in the log when provided", () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
devOnCaughtError(new Error("boom"), {
|
|
componentStack: "\n at List (apps/list.tsx:202)",
|
|
});
|
|
expect(consoleSpy).toHaveBeenCalledTimes(2);
|
|
expect(String(consoleSpy.mock.calls[1][0])).toContain("apps/list.tsx:202");
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does not re-dispatch a window 'error' event (would trigger Vite overlay)", () => {
|
|
// This test runs in a Node environment where `window` is undefined, so the
|
|
// listener registration is skipped and windowErrorCount stays 0 trivially.
|
|
// The test still documents the contract: devOnCaughtError must not dispatch
|
|
// window error events (which would re-trigger the Vite overlay). If a DOM
|
|
// environment is ever added to this project, this will become a live check.
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
let windowErrorCount = 0;
|
|
const onError = (): void => {
|
|
windowErrorCount += 1;
|
|
};
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("error", onError);
|
|
}
|
|
try {
|
|
devOnCaughtError(new Error("caught by user error.tsx"), {});
|
|
expect(windowErrorCount).toBe(0);
|
|
} finally {
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("error", onError);
|
|
}
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("is not a no-op (regression guard against `() => {}`)", () => {
|
|
// Explicit regression guard: the original implementation was `() => {}`,
|
|
// which silently swallowed all caught errors. This test ensures the handler
|
|
// always calls console.error at least once.
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
devOnCaughtError(new Error("regression"), {});
|
|
expect(consoleSpy.mock.calls.length).toBeGreaterThan(0);
|
|
} finally {
|
|
consoleSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("mounted slot helpers", () => {
|
|
it("collects only mounted slot ids", () => {
|
|
const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, {
|
|
"layout:/": React.createElement("div", null, "layout"),
|
|
"slot:modal:/": React.createElement("div", null, "modal"),
|
|
"slot:sidebar:/": React.createElement("div", null, "sidebar"),
|
|
"slot:ghost:/": null,
|
|
"slot:missing:/": UNMATCHED_SLOT,
|
|
});
|
|
|
|
expect(getMountedSlotIds(elements)).toEqual(["slot:modal:/", "slot:sidebar:/"]);
|
|
});
|
|
|
|
it("serializes mounted slot ids into a stable header value", () => {
|
|
const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, {
|
|
"slot:z:/": React.createElement("div", null, "z"),
|
|
"slot:a:/": React.createElement("div", null, "a"),
|
|
});
|
|
|
|
expect(getMountedSlotIdsHeader(elements)).toBe("slot:a:/ slot:z:/");
|
|
});
|
|
|
|
it("returns null when there are no mounted slots", () => {
|
|
const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, {
|
|
"slot:ghost:/": null,
|
|
"slot:missing:/": UNMATCHED_SLOT,
|
|
});
|
|
|
|
expect(getMountedSlotIdsHeader(elements)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolveServerActionRequestState", () => {
|
|
it("includes only Accept and x-rsc-action when previousNextUrl is null and no slots are mounted", () => {
|
|
const elements = createResolvedElements("route:/settings", "/");
|
|
|
|
const { headers } = resolveServerActionRequestState({
|
|
actionId: "action-abc",
|
|
basePath: "",
|
|
elements,
|
|
previousNextUrl: null,
|
|
});
|
|
|
|
expect(Array.from(headers.keys()).sort()).toEqual(["accept", "x-rsc-action"]);
|
|
expect(headers.get("accept")).toBe("text/x-component");
|
|
expect(headers.get("x-rsc-action")).toBe("action-abc");
|
|
});
|
|
|
|
it("derives X-Vinext-Interception-Context from previousNextUrl", () => {
|
|
const elements = createResolvedElements("route:/photos/42", "/");
|
|
const previousNextUrl = "/feed?tab=latest";
|
|
|
|
const { headers } = resolveServerActionRequestState({
|
|
actionId: "bump-likes",
|
|
basePath: "",
|
|
elements,
|
|
previousNextUrl,
|
|
});
|
|
|
|
expect(headers.get("X-Vinext-Interception-Context")).toBe(
|
|
resolveInterceptionContextFromPreviousNextUrl(previousNextUrl, ""),
|
|
);
|
|
});
|
|
|
|
it("strips the base path when deriving the interception context", () => {
|
|
const elements = createResolvedElements("route:/photos/42", "/");
|
|
const previousNextUrl = "/app/feed";
|
|
|
|
const { headers } = resolveServerActionRequestState({
|
|
actionId: "bump-likes",
|
|
basePath: "/app",
|
|
elements,
|
|
previousNextUrl,
|
|
});
|
|
|
|
expect(headers.get("X-Vinext-Interception-Context")).toBe(
|
|
resolveInterceptionContextFromPreviousNextUrl(previousNextUrl, "/app"),
|
|
);
|
|
});
|
|
|
|
it("derives X-Vinext-Mounted-Slots from mounted slot keys", () => {
|
|
const elements: AppElements = createResolvedElements("route:/feed", "/", null, {
|
|
"slot:@modal:/feed": React.createElement("div", null, "modal"),
|
|
"slot:@sidebar:/feed": React.createElement("div", null, "sidebar"),
|
|
});
|
|
|
|
const { headers } = resolveServerActionRequestState({
|
|
actionId: "action-x",
|
|
basePath: "",
|
|
elements,
|
|
previousNextUrl: null,
|
|
});
|
|
|
|
expect(headers.get("X-Vinext-Mounted-Slots")).toBe(getMountedSlotIdsHeader(elements));
|
|
});
|
|
|
|
it("omits headers whose derived values are null", () => {
|
|
const elements: AppElements = createResolvedElements("route:/settings", "/", null, {
|
|
"slot:ghost:/": null,
|
|
"slot:missing:/": UNMATCHED_SLOT,
|
|
});
|
|
|
|
const { headers } = resolveServerActionRequestState({
|
|
actionId: "action-y",
|
|
basePath: "",
|
|
elements,
|
|
previousNextUrl: null,
|
|
});
|
|
|
|
expect(headers.has("X-Vinext-Interception-Context")).toBe(false);
|
|
expect(headers.has("X-Vinext-Mounted-Slots")).toBe(false);
|
|
});
|
|
});
|