mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
c6f2a877d0
* fix(app-router): stream generated metadata after the document shell Dynamic App Router document renders currently await generateMetadata before constructing the page element tree. This delays response headers and the first HTML chunk for streaming-capable clients. Head resolution coupled metadata and viewport into one awaited result, so the renderer could not suspend only metadata. Start the branches independently, keep blocking callers unchanged, and expose metadata through paired Suspense tag and error outlets. The hidden host wrapper preserves React's shell flush in the presence of hoistable metadata tags. The focused head test verifies metadata remains pending while viewport resolution completes. * test(app-router): verify generated metadata follows the production shell Production coverage only established that HTML eventually arrived, so delaying the first byte until generateMetadata completed remained undetected. Read the production response incrementally and require the page shell to precede delayed metadata while still asserting the final tags. * test(app-router): align metadata error coverage with streamed responses Streaming generateMetadata errors return a recoverable 200 shell before local or global boundaries render after hydration. The compatibility suite incorrectly expected those boundaries and 500 statuses in raw server HTML, which conflicts with Next.js 16.2.7 and fails the integration shard. Update raw-response assertions to cover shell behavior and add browser coverage for page and layout metadata errors with and without local boundaries. * fix(app-router): stream metadata during navigation * fix(app-router): isolate connection probes from streaming metadata Dynamic metadata prefetches can leave Flight responses open indefinitely when generateMetadata calls connection(). Streaming starts metadata in parallel with page classification, but the speculative probe mutated shared request state and captured the sibling metadata branch. Run probes in a nested request scope, then propagate dynamic usage and new diagnostic errors back with concurrency-safe rules. This preserves classification while allowing sibling metadata work to complete. * refactor(app-router): isolate fallback metadata planning Streamed metadata fallbacks previously configured the general head resolver with traversal flags for boundary repetition, leaf ordering, and viewport suppression. That made fallback policy part of normal metadata resolution and obscured the RSC navigation transport contract. Build an explicit HTTP-access fallback metadata source plan, then resolve it through the shared ordered metadata merger. Add focused planner coverage and pin delayed navigation redirects to HTTP 200 Flight digest transport. * fix(app-router): preserve not-found metadata search params Page-local not-found metadata lost searchParams after deferred metadata called notFound(), so query-derived tags were wrong and search access was invisible to dynamic-usage tracking. Terminal fallback rendering also recomputed the boundary head without the normalized query. Classify not-found ownership from module identity and tree position, attach query state and its observer only for page-owned conventions, and thread normalized search params through terminal fallback rendering. Cover repeated fallback leaves, observer access, and a production page-local not-found route. * fix(app-router): release completed connection probes Async work created inside a speculative connection probe retained the child request store after the probe returned. Because that store still referenced the completed probe, a later connection() call suspended forever. Restore the child store's currently inherited probe during deterministic cleanup. This preserves nested probe ownership while allowing late continuations to observe the completed scope, with a real AsyncLocalStorage regression covering the lifecycle. * fix(app-router): preserve deferred metadata cache signals Deferred metadata dynamic usage can overlap a speculative layout probe. The probe cleared and later consumed the shared request flag, allowing an RSC cache entry to be written even though the completed response was marked no-store. The layout classifier treated save-and-restore mutation as async isolation. Run probe classification in a child dynamic-usage scope so sibling metadata retains the parent request state, and cover the overlap through the dispatch cache boundary. * fix(app-router): preserve fallback metadata parity --------- Co-authored-by: James <james@eli.cx>
207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
ensureAppRouteModulesLoaded,
|
|
loadAppInterceptLayouts,
|
|
type LazyLoadableRoute,
|
|
} from "../packages/vinext/src/server/app-route-module-loader.js";
|
|
|
|
describe("ensureAppRouteModulesLoaded", () => {
|
|
it("returns the route synchronously when there are no lazy thunks (eager route)", () => {
|
|
const pageModule = { default: () => null };
|
|
const route: LazyLoadableRoute = { page: pageModule };
|
|
|
|
const result = ensureAppRouteModulesLoaded(route);
|
|
|
|
// No promise — eager routes resolve synchronously.
|
|
expect(result).toBe(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.__loaded).toBe(true);
|
|
});
|
|
|
|
it("hydrates a lazy page module onto route.page", async () => {
|
|
const pageModule = { default: () => null, generateMetadata: () => ({}) };
|
|
const __loadPage = vi.fn(async () => pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
const loaded = await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(loaded).toBe(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.routeHandler).toBeUndefined();
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("hydrates a lazy route-handler module onto route.routeHandler", async () => {
|
|
const handlerModule = { GET: () => new Response("ok") };
|
|
const __loadRouteHandler = vi.fn(async () => handlerModule);
|
|
const route: LazyLoadableRoute = { routeHandler: null, __loadRouteHandler };
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.routeHandler).toBe(handlerModule);
|
|
});
|
|
|
|
it("loads both page and route handler in parallel", async () => {
|
|
const pageModule = { default: () => null };
|
|
const handlerModule = { POST: () => new Response() };
|
|
const route: LazyLoadableRoute = {
|
|
page: null,
|
|
routeHandler: null,
|
|
__loadPage: async () => pageModule,
|
|
__loadRouteHandler: async () => handlerModule,
|
|
};
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.routeHandler).toBe(handlerModule);
|
|
});
|
|
|
|
it("is idempotent: a second call does not re-import", async () => {
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi.fn(async () => pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
const second = ensureAppRouteModulesLoaded(route);
|
|
|
|
// Already loaded → returns the route synchronously (not a promise).
|
|
expect(second).toBe(route);
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dedups concurrent calls into a single import", async () => {
|
|
let resolveImport: (mod: unknown) => void = () => {};
|
|
const importPromise = new Promise((resolve) => {
|
|
resolveImport = resolve;
|
|
});
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi.fn(() => importPromise);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
const a = ensureAppRouteModulesLoaded(route);
|
|
const b = ensureAppRouteModulesLoaded(route);
|
|
|
|
// Both callers observe the same in-flight promise.
|
|
expect(a).toBe(b);
|
|
resolveImport(pageModule);
|
|
await Promise.all([a, b]);
|
|
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
expect(route.page).toBe(pageModule);
|
|
});
|
|
|
|
it("does not cache a failed import: re-throws and retries on the next call", async () => {
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("chunk load failed"))
|
|
.mockResolvedValueOnce(pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
// First call rejects and the rejection propagates to the caller.
|
|
await expect(ensureAppRouteModulesLoaded(route)).rejects.toThrow("chunk load failed");
|
|
// The failure is not stuck: state is reset for a retry.
|
|
expect(route.__loaded).toBeFalsy();
|
|
expect(route.__loading).toBeNull();
|
|
|
|
// Next call retries the import and succeeds.
|
|
await ensureAppRouteModulesLoaded(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(__loadPage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("tolerates null / undefined routes", () => {
|
|
expect(ensureAppRouteModulesLoaded(null)).toBeNull();
|
|
expect(ensureAppRouteModulesLoaded(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("hydrates array module fields positionally, skipping null loaders and pre-filled slots", async () => {
|
|
const rootLayout = { default: () => null };
|
|
const childLayout = { default: () => null };
|
|
const eagerLayout = { default: () => null };
|
|
// Index 1 has a null loader (no module at that position); index 2 is already
|
|
// populated and must not be re-imported — mirrors the manifest emitting
|
|
// `[null, load_x]` plus an eagerly-set entry.
|
|
const __loadLayouts = [vi.fn(async () => rootLayout), null, vi.fn(async () => childLayout)];
|
|
const route: LazyLoadableRoute = {
|
|
layouts: [null, null, eagerLayout],
|
|
__loadLayouts,
|
|
};
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.layouts).toEqual([rootLayout, null, eagerLayout]);
|
|
expect(__loadLayouts[0]).toHaveBeenCalledTimes(1);
|
|
expect(__loadLayouts[2]).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores array loaders beyond the manifest placeholder length", async () => {
|
|
const layout = { default: () => null };
|
|
const outOfRangeLoader = vi.fn(async () => layout);
|
|
const route: LazyLoadableRoute = {
|
|
layouts: [null],
|
|
__loadLayouts: [null, outOfRangeLoader],
|
|
};
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.layouts).toEqual([null]);
|
|
expect(outOfRangeLoader).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydrates parallel-slot modules onto each slot", async () => {
|
|
const slotPage = { default: () => null };
|
|
const slotLayout = { default: () => null };
|
|
const slotNotFound = { default: () => null, metadata: { title: "slot not found" } };
|
|
const nestedSlotLayout = { default: () => null, revalidate: 30 };
|
|
const __loadPage = vi.fn(async () => slotPage);
|
|
const __loadLayout = vi.fn(async () => slotLayout);
|
|
const __loadNotFound = vi.fn(async () => slotNotFound);
|
|
const __loadConfigLayout = vi.fn(async () => nestedSlotLayout);
|
|
const route: LazyLoadableRoute = {
|
|
slots: {
|
|
"@modal": {
|
|
page: null,
|
|
layout: null,
|
|
configLayouts: [null],
|
|
__loadPage,
|
|
__loadLayout,
|
|
__loadNotFound,
|
|
__loadConfigLayouts: [__loadConfigLayout],
|
|
},
|
|
},
|
|
};
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.slots?.["@modal"].page).toBe(slotPage);
|
|
expect(route.slots?.["@modal"].layout).toBe(slotLayout);
|
|
expect(route.slots?.["@modal"].notFound).toBe(slotNotFound);
|
|
expect(route.slots?.["@modal"].configLayouts).toEqual([nestedSlotLayout]);
|
|
});
|
|
});
|
|
|
|
describe("loadAppInterceptLayouts", () => {
|
|
it("hydrates intercept layouts from their loaders and returns the array", async () => {
|
|
const layoutA = { default: () => null };
|
|
const layoutB = { default: () => null };
|
|
const intercept = {
|
|
interceptLayouts: [null, null],
|
|
__loadInterceptLayouts: [async () => layoutA, async () => layoutB],
|
|
};
|
|
|
|
const result = await loadAppInterceptLayouts(intercept);
|
|
|
|
expect(intercept.interceptLayouts).toEqual([layoutA, layoutB]);
|
|
expect(result).toBe(intercept.interceptLayouts);
|
|
});
|
|
|
|
it("resolves synchronously to the existing array when there are no loaders", () => {
|
|
const intercept = { interceptLayouts: [] as unknown[] };
|
|
|
|
// No loaders → returns a resolved promise wrapping the same array, no imports.
|
|
return expect(loadAppInterceptLayouts(intercept)).resolves.toBe(intercept.interceptLayouts);
|
|
});
|
|
});
|