mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b4c829d6a2
* test(interception): add failing sibling-interception graph tests * feat(routing): discover sibling-style interception markers * feat(routing): emit sibling interception manifest facts * feat(app-router): serialize sibling intercepts in route manifest * feat(app-router): match sibling intercepts in route matcher * feat(app-router): render sibling intercept as full page response * test: add siblingIntercepts field to AppRoute test fixtures * test(interception): add sibling interception fixtures * fix(app-router): emit null proof for sibling intercepts to bypass slot-validation * test(interception): add sibling interception e2e specs * fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture * fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots, the inherited slot's interceptingRoutes are copied from the ancestor. Previously, createInterceptLookup used routeIndex (the inheriting descendant's index) as sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute === currentRoute → kind='current-route' instead of kind='source-route'. Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's sourceMatchPattern (which names the actual slot-owner route), falling back to routeIndex only when no match is found. * fix(interception): use locale-prefixed href so middleware interception fires correctly * fix(interception): address codex review issues in sibling intercept implementation - Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport sees the intercepted route's params instead of the source route's params - Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept to prevent collision with a user @__page parallel route directory - Restore middleware-rewrite fixture link without locale prefix so the interception-dynamic-segment-middleware spec exercises the intended middleware rewrite path * fix(test): update slotId assertions and restore locale-prefixed fixture link - Update test assertions from slot:__page to slot:__vinext_sibling_intercept following the rename in commit2248b8fe- Restore locale-prefixed href in interception-mw fixture so the interception-dynamic-segment-middleware E2E tests pass (reverts the accidental revert from2248b8feback to the working state from02e80c26) * fix(interception): address review findings on sibling interception PR - Restore locale-less href in interception-mw fixture and fix the underlying middleware-rewrite interception gap: add a Referer-based fallback in app-rsc-handler so interception fires when middleware rewrites the URL before the server matches it (the client-side manifest check sees the pre-rewrite target and skips setting the interception context header; the Referer carries the source page the server needs) - Apply interceptLayouts for sibling intercepts in buildPageElements so a layout.tsx under the interception marker dir wraps the intercepting page, matching Next.js segment-layout wrapping behaviour; add fixture layout and E2E assertion - Add unit test proving findOwnerRouteForDir ancestor walk attaches a sibling intercept to the nearest ancestor route when the marker's parent dir has no page.tsx (not dropped silently) * fix(interception): send current pathname as fallback interception context The client-side manifest check compares the pre-middleware target URL against declared interception patterns. When middleware rewrites the URL (e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments and doesn't match the pattern, so no X-Vinext-Interception-Context header is sent and the server cannot fire interception. Fix: when the manifest check yields no match and there is no prior intercepted navigation in browser state, fall back to sending the current page pathname as the interception context. This mirrors Next.js sending Next-URL on every RSC navigation. The server's findIntercept is double-gated on both source and target matching, so false positives cannot occur. The previousNextUrl is also populated so back/forward traversal can restore intercepted state. Revert the Referer-based server-side fallback (unreliable across environments) in favour of this explicit browser-side fix. * fix(interception): gate middleware-rewrite fallback on declared source pattern The previous fix sent window.location.pathname as fallback interception context on every unmatched navigation, which broke prefetch cache reuse: a prefetch stored with null context wouldn't be found when the navigation used a non-null context key. Refine the fallback to only fire when the current page URL matches at least one sourcePatternParts declared in the route manifest — i.e., the current page is a known interception source. This preserves prefetch cache reuse for ordinary navigations while still enabling middleware- rewrite interception for pages that declare an interception relationship. * fix(interception): support middleware-rewrite targets in fallback context and planner The previous fallback-context gate only checked whether the current page is a declared interception source; it did not verify the target URL could plausibly be a middleware-rewritten version of the declared target pattern. This meant the client sent interception context when it shouldn't (pre-middleware target had no static anchors in common with the pattern) and the planner hard-navigated when the proof target matched only after the rewrite. Three coordinated fixes: 1. Client fallback gate (app-browser-entry / app-browser-interception-context): replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments on the target side — the pre-middleware URL must still share every static anchor of the declared target pattern, in order, while allowing dynamic segments to be absent. 2. Planner interception proof (navigation-planner): findRouteManifestInterceptionForProof now accepts a proof whose target matched URL equals the pre-rewrite canonical pathname (shorter than the full pattern) when matchRoutePatternWithOptionalDynamicSegments passes. Exact pattern match still skips the targetRouteId guard; the relaxed path skips it too so the middleware-added locale segment does not invalidate an otherwise valid proof. 3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder): the handler now passes canonicalPathname as displayPathname separately from cleanPathname. buildPageElements uses cleanPathname for all internal routing work (slot param extraction, slot overrides) and displayPathname only for render identity — keeping the browser-visible URL in RSC metadata without affecting internal matching. * fix(interception): guard siblingInterceptElement construction and document reserved slot name * fix(interception): surface missing default export on sibling intercept pages A sibling-style interception whose intercepting page module is missing its `default` export previously fell back to the source route's page component (`effectivePageModule?.default ?? PageComponent`), silently rendering a *different* page than the one requested. Resolve the effective component from the intercepting page only and extend the existing no-default-export guard to cover sibling intercepts, so a missing export surfaces the same explicit "Page has no default export" error the source/slot paths already produce. For a normal request this is identical to the previous behavior since `effectivePageModule === pageModule`. Addresses bonk review feedback on #1804. * test(app-router): guard canonical pathname commit on soft-nav config rewrites The `displayPathname = canonicalPathname` change feeds the RSC payload identity the client planner uses to commit a navigation, so it affects every rewritten route, not just interceptions. Existing coverage (nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path. Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten route and asserts the committed URL and `usePathname()` stay canonical (pre-rewrite), with a marker check proving no full page reload occurred. Addresses bonk review feedback on #1804. * fix(interception): restore __pageLoader on slot intercept lookup entries The main merge auto-resolved incorrectly: it moved the `__pageLoader` field that #1738 added to the *slot* intercept push block onto the new *sibling* push block instead (the two `interceptLookup.push({...})` calls are textually similar). As a result slot intercept lookup entries lost `__pageLoader`, so resolveAppPageInterceptState never loaded the lazy intercepting page (`page` stayed null) and the modal slot fell back to default.tsx — breaking every slot interception. Restore `__pageLoader: intercept.__pageLoader` on the slot push block. The sibling block already carries it (see merge commit), and both intercept types declare the optional field. Fixes the app-router-dev-server intercept tests and the interception e2e suites that regressed after merging main. --------- Co-authored-by: James Anderson <james@eli.cx>
1070 lines
35 KiB
TypeScript
1070 lines
35 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import React from "react";
|
|
import {
|
|
APP_INTERCEPTION_KEY,
|
|
APP_INTERCEPTION_CONTEXT_KEY,
|
|
APP_LAYOUT_IDS_KEY,
|
|
APP_ROOT_LAYOUT_KEY,
|
|
APP_ROUTE_KEY,
|
|
APP_SLOT_BINDINGS_KEY,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import type { AppPageModule } from "../packages/vinext/src/server/app-page-route-wiring.js";
|
|
import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js";
|
|
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
|
|
import { readStreamAsText } from "../packages/vinext/src/utils/text-stream.js";
|
|
|
|
// Import the function under test AFTER mocking dependencies.
|
|
// eslint-disable-next-line import/first
|
|
import { buildPageElements } from "../packages/vinext/src/server/app-page-element-builder.js";
|
|
import type { AppPageBuildRoute } from "../packages/vinext/src/server/app-page-element-builder.js";
|
|
import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js";
|
|
import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { markDynamicUsageMock, markRenderRequestApiUsageMock } = vi.hoisted(() => ({
|
|
markDynamicUsageMock: vi.fn(),
|
|
markRenderRequestApiUsageMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../packages/vinext/src/shims/headers.js", () => ({
|
|
markDynamicUsage: markDynamicUsageMock,
|
|
markRenderRequestApiUsage: markRenderRequestApiUsageMock,
|
|
throwIfInsideCacheScope: vi.fn(),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createSyntheticRoute(overrides?: Partial<AppPageBuildRoute>): AppPageBuildRoute {
|
|
return {
|
|
layouts: [],
|
|
pattern: "/test",
|
|
routeSegments: [] as readonly string[],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createSyntheticPageModule(defaultExport?: unknown): AppPageModule {
|
|
if (defaultExport !== undefined) {
|
|
return { default: defaultExport } as AppPageModule;
|
|
}
|
|
return {} as AppPageModule;
|
|
}
|
|
|
|
function createSyntheticPageModuleWithoutDefault(): AppPageModule {
|
|
return { generateMetadata: vi.fn() } as AppPageModule;
|
|
}
|
|
|
|
function createBaseOptions(overrides?: {
|
|
route?: AppPageBuildRoute;
|
|
params?: AppPageParams;
|
|
routePath?: string;
|
|
opts?: Record<string, unknown> | null;
|
|
searchParams?: URLSearchParams | null;
|
|
mountedSlotsHeader?: string | null;
|
|
}) {
|
|
return {
|
|
route:
|
|
overrides?.route ?? createSyntheticRoute({ page: createSyntheticPageModule(() => null) }),
|
|
params: overrides?.params ?? {},
|
|
routePath: overrides?.routePath ?? "/test",
|
|
pageRequest: {
|
|
opts: overrides?.opts ?? null,
|
|
searchParams: overrides?.searchParams ?? null,
|
|
isRscRequest: false,
|
|
request: new Request("http://localhost/test"),
|
|
mountedSlotsHeader: overrides?.mountedSlotsHeader ?? null,
|
|
},
|
|
globalErrorModule: null,
|
|
rootNotFoundModule: null,
|
|
rootForbiddenModule: null,
|
|
rootUnauthorizedModule: null,
|
|
metadataRoutes: [],
|
|
};
|
|
}
|
|
|
|
async function buildSearchPageSearchParams(options?: {
|
|
loadingBoundary?: boolean;
|
|
}): Promise<{ searchParams: Promise<Record<string, unknown>> }> {
|
|
function SearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(SearchPage),
|
|
loading: options?.loadingBoundary ? { default: () => null } : undefined,
|
|
layouts: [],
|
|
routeSegments: ["search"],
|
|
pattern: "/search",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/search",
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const pageElement = record["page:/search"];
|
|
if (!React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement)) {
|
|
throw new Error("Expected page element");
|
|
}
|
|
if (!pageElement.props.searchParams) {
|
|
throw new Error("Expected searchParams prop");
|
|
}
|
|
|
|
return { searchParams: pageElement.props.searchParams };
|
|
}
|
|
|
|
async function resetUseCacheRuntime(): Promise<void> {
|
|
const { MemoryCacheHandler, setCacheHandler } =
|
|
await import("../packages/vinext/src/shims/cache.js");
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
}
|
|
|
|
async function renderNode(node: React.ReactNode): Promise<string> {
|
|
const { renderToReadableStream } = await import("react-dom/server.edge");
|
|
const stream = await renderToReadableStream(node, {
|
|
onError(error: unknown) {
|
|
throw error instanceof Error ? error : new Error(String(error));
|
|
},
|
|
});
|
|
return readStreamAsText(stream);
|
|
}
|
|
|
|
async function buildAndRenderElement(
|
|
route: AppPageBuildRoute,
|
|
elementId: string,
|
|
query: string,
|
|
): Promise<string> {
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
params: { slug: "same" },
|
|
routePath: "/cached",
|
|
searchParams: new URLSearchParams({ q: query }),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const element = record[elementId];
|
|
if (!React.isValidElement(element)) {
|
|
throw new Error(`Expected React element for ${elementId}`);
|
|
}
|
|
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
return renderNode(element);
|
|
}
|
|
|
|
function expectNoSearchParamsObservation(): void {
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
}
|
|
|
|
async function expectCachedRenderIgnoresQuery(options: {
|
|
expectedText: string;
|
|
getCallCount: () => number;
|
|
render: (query: string) => Promise<string>;
|
|
}): Promise<void> {
|
|
await expect(options.render("first")).resolves.toContain(options.expectedText);
|
|
expect(options.getCallCount()).toBe(1);
|
|
expectNoSearchParamsObservation();
|
|
|
|
await expect(options.render("second")).resolves.toContain(options.expectedText);
|
|
expect(options.getCallCount()).toBe(1);
|
|
expectNoSearchParamsObservation();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("buildPageElements", () => {
|
|
beforeEach(() => {
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
});
|
|
|
|
it("returns an error element record when a page module has no default export", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [],
|
|
routeSegments: ["test"],
|
|
pattern: "/test",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
// The error payload uses the AppElementsWire route-id encoder for both
|
|
// the entry key and the __route metadata.
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/test");
|
|
expect(record[APP_INTERCEPTION_CONTEXT_KEY]).toBe(null);
|
|
expect(Object.prototype.hasOwnProperty.call(record, APP_ROOT_LAYOUT_KEY)).toBe(true);
|
|
// The element itself is stored under the route ID key.
|
|
expect(record["route:/test"]).toBeDefined();
|
|
});
|
|
|
|
it("surfaces a no-default-export error for a sibling intercept page instead of rendering the source page", async () => {
|
|
function SourcePage(): React.ReactNode {
|
|
return React.createElement("div", null, "Source page content");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
// The source route has a valid default export, so the dispatch-level
|
|
// no-export guard passes and the request reaches buildPageElements.
|
|
page: createSyntheticPageModule(SourcePage),
|
|
layouts: [],
|
|
routeSegments: ["photo", "[id]"],
|
|
pattern: "/photo/[id]",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photo/42",
|
|
opts: {
|
|
interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
|
// The intercepting page module is missing its `default` export.
|
|
interceptPage: createSyntheticPageModuleWithoutDefault(),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const routeKey = record[APP_ROUTE_KEY] as string;
|
|
const errorElement = record[routeKey];
|
|
expect(React.isValidElement(errorElement)).toBe(true);
|
|
|
|
const html = await renderNode(errorElement as React.ReactNode);
|
|
// The error is surfaced explicitly rather than silently falling back to
|
|
// the source route's page component.
|
|
expect(html).toContain("Page has no default export");
|
|
expect(html).not.toContain("Source page content");
|
|
});
|
|
|
|
it("keeps interception context out of the error payload route ID", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [],
|
|
routeSegments: ["intercepted"],
|
|
pattern: "/intercepted",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/intercepted",
|
|
opts: { interceptionContext: "ctx-abc" } as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/intercepted");
|
|
expect(record[APP_INTERCEPTION_CONTEXT_KEY]).toBe("ctx-abc");
|
|
});
|
|
|
|
it("computes root layout tree path for error payload when layouts exist", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [createSyntheticPageModule(() => null), createSyntheticPageModule(() => null)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["dashboard", "settings"],
|
|
pattern: "/dashboard/settings",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROOT_LAYOUT_KEY]).toBe("/");
|
|
expect(record[APP_LAYOUT_IDS_KEY]).toEqual(["layout:/", "layout:/dashboard"]);
|
|
});
|
|
|
|
it("constructs a full element tree for a page with a default export", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [],
|
|
routeSegments: ["hello"],
|
|
pattern: "/hello",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route, routePath: "/hello" }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
// Normal flow: the element tree has both route and page payload IDs.
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/hello");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/hello")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/hello")).toBe(true);
|
|
});
|
|
|
|
it("attaches route-state slot bindings for active, default, and unmatched slots", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0],
|
|
routeSegments: ["dashboard"],
|
|
pattern: "/dashboard",
|
|
slots: {
|
|
"team@dashboard/@team": {
|
|
id: "slot:team:/",
|
|
name: "team",
|
|
page: createSyntheticPageModule(() => React.createElement("div", null, "team")),
|
|
layoutIndex: 0,
|
|
routeSegments: [],
|
|
},
|
|
"analytics@dashboard/@analytics": {
|
|
id: "slot:analytics:/",
|
|
name: "analytics",
|
|
default: createSyntheticPageModule(() => React.createElement("div", null, "analytics")),
|
|
layoutIndex: 0,
|
|
routeSegments: null,
|
|
},
|
|
"reports@dashboard/@reports": {
|
|
id: "slot:reports:/",
|
|
name: "reports",
|
|
layoutIndex: 0,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route, routePath: "/dashboard" }));
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_SLOT_BINDINGS_KEY]).toEqual([
|
|
{
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:analytics:/",
|
|
state: "default",
|
|
},
|
|
{
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:reports:/",
|
|
state: "unmatched",
|
|
},
|
|
{
|
|
activeRouteId: "route:/dashboard",
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:team:/",
|
|
state: "active",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("marks intercepted slot override bindings as active", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/feed",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/42",
|
|
opts: {
|
|
interceptionContext: "/feed",
|
|
interceptSlotKey: "modal@feed/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_SLOT_BINDINGS_KEY]).toEqual([
|
|
{
|
|
activeRouteId: "route:/photos/42",
|
|
ownerLayoutId: "layout:/feed",
|
|
slotId: "slot:modal:/feed",
|
|
state: "active",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses the source route identity for intercepted source-route payload keys", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Feed");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/feed",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/42",
|
|
opts: {
|
|
interceptionContext: "/feed",
|
|
interceptSourceMatchedUrl: "/feed",
|
|
interceptSlotId: "slot:modal:/feed",
|
|
interceptSlotKey: "modal@feed/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/feed");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/feed")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/photos/42\0/feed")).toBe(false);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/photos/42\0/feed")).toBe(false);
|
|
expect(record[APP_INTERCEPTION_KEY]).toEqual({
|
|
sourceMatchedUrl: "/feed",
|
|
sourceRouteId: "route:/feed",
|
|
slotId: "slot:modal:/feed",
|
|
targetMatchedUrl: "/photos/42",
|
|
targetRouteId: "route:/photos/42",
|
|
});
|
|
});
|
|
|
|
it("normalizes encoded interception proof paths before encoding route IDs", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["café"],
|
|
pattern: "/café",
|
|
slots: {
|
|
"modal@café/@modal": {
|
|
id: "slot:modal:/café",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/caf%C3%A9",
|
|
opts: {
|
|
interceptionContext: "/caf%C3%A9",
|
|
interceptSourceMatchedUrl: "/caf%C3%A9",
|
|
interceptSlotId: "slot:modal:/café",
|
|
interceptSlotKey: "modal@café/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "café" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_INTERCEPTION_KEY]).toEqual({
|
|
sourceMatchedUrl: "/café",
|
|
sourceRouteId: "route:/café",
|
|
slotId: "slot:modal:/café",
|
|
targetMatchedUrl: "/photos/café",
|
|
targetRouteId: "route:/photos/café",
|
|
});
|
|
});
|
|
|
|
it("rejects graph slot ids that diverge from the wire slot id", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/wrong",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
buildPageElements(createBaseOptions({ route, routePath: "/feed" })),
|
|
).rejects.toThrow("App Router slot id mismatch");
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage while wiring searchParams into the render tree", async () => {
|
|
function SearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(SearchPage),
|
|
layouts: [],
|
|
routeSegments: ["search"],
|
|
pattern: "/search",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/search",
|
|
searchParams: new URLSearchParams(""),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const pageElement = record["page:/search"];
|
|
expect(
|
|
React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement),
|
|
).toBe(true);
|
|
|
|
if (!React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement)) {
|
|
throw new Error("Expected page element");
|
|
}
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps loading-boundary render-tree searchParams status inspection inert", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams({ loadingBoundary: true });
|
|
|
|
Reflect.get(searchParams, "status");
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps non-loading render-tree searchParams await inert", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams();
|
|
|
|
await searchParams;
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("observes loading-boundary render-tree searchParams await as dynamic access", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams({ loadingBoundary: true });
|
|
|
|
await searchParams;
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("keeps a cached primary page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
let pageCalls = 0;
|
|
const CachedPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<{ slug: string }> }): Promise<string> => {
|
|
pageCalls++;
|
|
const resolvedParams = await params;
|
|
return `primary:${resolvedParams.slug}`;
|
|
},
|
|
"/fixture/app/cached/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(CachedPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
});
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "primary:same",
|
|
getCallCount: () => pageCalls,
|
|
render: (query) => buildAndRenderElement(route, "page:/cached", query),
|
|
});
|
|
});
|
|
|
|
it("keeps a cached active slot page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
|
|
let slotCalls = 0;
|
|
const CachedSlotPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<Record<string, unknown>> }): Promise<string> => {
|
|
slotCalls++;
|
|
await params;
|
|
return "slot:cached";
|
|
},
|
|
"/fixture/app/cached/@modal/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
page: createSyntheticPageModule(CachedSlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "slot:cached",
|
|
getCallCount: () => slotCalls,
|
|
render: (query) => buildAndRenderElement(route, "slot:modal:/", query),
|
|
});
|
|
});
|
|
|
|
it("keeps a cached intercepting slot page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
|
|
let interceptCalls = 0;
|
|
const CachedInterceptPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<Record<string, unknown>> }): Promise<string> => {
|
|
interceptCalls++;
|
|
await params;
|
|
return "intercept:cached";
|
|
},
|
|
"/fixture/app/cached/@modal/(.)photo/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
const renderIntercept = async (query: string): Promise<string> => {
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/cached",
|
|
searchParams: new URLSearchParams({ q: query }),
|
|
opts: {
|
|
interceptSlotKey: "@modal",
|
|
interceptPage: createSyntheticPageModule(CachedInterceptPage),
|
|
},
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const element = record["slot:modal:/"];
|
|
if (!React.isValidElement(element)) {
|
|
throw new Error("Expected intercepting slot element");
|
|
}
|
|
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
return renderNode(element);
|
|
};
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "intercept:cached",
|
|
getCallCount: () => interceptCalls,
|
|
render: renderIntercept,
|
|
});
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage just because the request query has content", async () => {
|
|
function NoSearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "No Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(NoSearchPage),
|
|
layouts: [],
|
|
routeSegments: ["no-search"],
|
|
pattern: "/no-search",
|
|
});
|
|
|
|
await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/no-search",
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes slot overrides when interception opts have a slot key and page", async () => {
|
|
function InterceptPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Intercepted");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => React.createElement("div", null, "Main")),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
opts: {
|
|
interceptSlotKey: "modal",
|
|
interceptPage: createSyntheticPageModule(InterceptPage),
|
|
interceptLayouts: [
|
|
createSyntheticPageModule(() => React.createElement("div", null, "Layout")),
|
|
],
|
|
interceptParams: { id: "123" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/feed");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true);
|
|
});
|
|
|
|
it("builds elements for a page that receives search params", async () => {
|
|
function ParamPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Params");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(ParamPage),
|
|
layouts: [],
|
|
routeSegments: ["user", "[id]"],
|
|
pattern: "/user/[id]",
|
|
});
|
|
|
|
const params: AppPageParams = { id: "42" };
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/user/[id]",
|
|
params,
|
|
searchParams: new URLSearchParams("ref=source"),
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/user/[id]");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/user/[id]")).toBe(true);
|
|
});
|
|
|
|
it("passes page searchParams to active slot pages", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function SlotPage(): React.ReactNode {
|
|
return React.createElement("span", null, "slot");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
page: createSyntheticPageModule(SlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:modal:/"] as React.ReactElement<{
|
|
searchParams: PromiseLike<Record<string, unknown>>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
await expect(slotElement.props.searchParams).resolves.toEqual({ search: "hello" });
|
|
});
|
|
|
|
it("passes page searchParams to intercepting slot pages", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function InterceptPage(): React.ReactNode {
|
|
return React.createElement("span", null, "intercept");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
opts: {
|
|
interceptSlotKey: "@modal",
|
|
interceptPage: createSyntheticPageModule(InterceptPage),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:modal:/"] as React.ReactElement<{
|
|
searchParams: PromiseLike<Record<string, unknown>>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
await expect(slotElement.props.searchParams).resolves.toEqual({ search: "hello" });
|
|
});
|
|
|
|
it("extracts slot params from routePath, not request.url, so basePath does not break the match", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function SlotPage(): React.ReactNode {
|
|
return React.createElement("span", null, "slot");
|
|
}
|
|
|
|
// Inherited slot whose mirrored sub-page has a dynamic marker (`:name`)
|
|
// with a different name than the route's (`:id`). The slotPatternParts
|
|
// are app-relative — basePath was already stripped during graph build.
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["distinct", "[id]"],
|
|
pattern: "/distinct/[id]",
|
|
params: ["id"],
|
|
slots: {
|
|
"@bc": {
|
|
name: "bc",
|
|
page: createSyntheticPageModule(SlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: ["distinct", "[id]"],
|
|
slotPatternParts: ["distinct", ":name"],
|
|
slotParamNames: ["name"],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Simulate a basePath-configured app: request URL still carries `/base`,
|
|
// but the entry passes the cleaned pathname as routePath. The fix must
|
|
// match against routePath, not against `new URL(request.url).pathname`.
|
|
const result = await buildPageElements({
|
|
...createBaseOptions({
|
|
route,
|
|
params: { id: "alice" },
|
|
routePath: "/distinct/alice",
|
|
}),
|
|
pageRequest: {
|
|
opts: null,
|
|
searchParams: null,
|
|
isRscRequest: false,
|
|
request: new Request("http://localhost/base/distinct/alice"),
|
|
mountedSlotsHeader: null,
|
|
},
|
|
});
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:bc:/"] as React.ReactElement<{
|
|
params: PromiseLike<AppPageParams>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
const slotParams = await slotElement.props.params;
|
|
// Without the fix, urlParts would be ["base","distinct","alice"], the
|
|
// pattern match would fail, and slotParams would silently fall back to
|
|
// the route's matched params ({ id: "alice" }) — leaving the slot
|
|
// page's `name` undefined. With the fix, the slot gets its own params.
|
|
expect(slotParams).toEqual({ name: "alice" });
|
|
});
|
|
|
|
it("makeThenableParams wraps params as a proxy supporting both Promise and property access", () => {
|
|
const plainParams: AppPageParams = { id: "99" };
|
|
const thenable = makeThenableParams(plainParams);
|
|
|
|
expect(typeof thenable.then).toBe("function");
|
|
expect(Reflect.get(thenable as object, "id")).toBe("99");
|
|
|
|
return thenable.then((resolved: AppPageParams) => {
|
|
expect(resolved).toEqual(plainParams);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("probeAppPage", () => {
|
|
beforeEach(() => {
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
});
|
|
|
|
it("calls markDynamicUsage when the page awaits searchParams, even when the query is empty", async () => {
|
|
async function SearchPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): Promise<React.ReactNode> {
|
|
await searchParams;
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
await Promise.resolve(
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: SearchPage,
|
|
searchParams: new URLSearchParams(""),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("calls markDynamicUsage when a returned server component consumes spread searchParams", async () => {
|
|
async function Child({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): Promise<React.ReactNode> {
|
|
await searchParams;
|
|
return React.createElement("div", null, "Child");
|
|
}
|
|
|
|
function SearchPage(props: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): React.ReactNode {
|
|
return React.createElement(Child, props);
|
|
}
|
|
|
|
await Promise.resolve(
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: SearchPage,
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage just because the request query has content", () => {
|
|
function NoSearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "No Search");
|
|
}
|
|
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: NoSearchPage,
|
|
searchParams: new URLSearchParams("q=test"),
|
|
});
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|