Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2382,6 +2382,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
Comment on lines +2389 to +2397
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this URLSearchParams-to-plain-object conversion is duplicated from buildPageElement() (lines 967-978). They're identical except for the hasSearchParams tracking. If you wanted to reduce the duplication you could extract a small searchParamsToObject(urlSearchParams) helper at the top of the generated entry (next to makeThenableParams), but given this is codegen and the AGENTS.md guidance to keep entries thin, I wouldn't block on it — just flagging for awareness in case this conversion shows up a third time.

return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -2427,7 +2440,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down
90 changes: 84 additions & 6 deletions tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -2123,7 +2136,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down Expand Up @@ -4275,6 +4288,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -4320,7 +4346,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down Expand Up @@ -6478,6 +6504,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -6523,7 +6562,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down Expand Up @@ -8705,6 +8744,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -8750,7 +8802,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down Expand Up @@ -10906,6 +10958,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -10951,7 +11016,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down Expand Up @@ -13464,6 +13529,19 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// rscCssTransform — no manual loadCss() call needed.
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
const _asyncLayoutParams = makeThenableParams(params);
// Convert URLSearchParams to a plain object then wrap in makeThenableParams()
// so probePage() passes the same shape that buildPageElement() gives to the
// real render. Without this, pages that destructure await-ed searchParams
// throw TypeError during probe.
const _probeSearchObj = {};
url.searchParams.forEach(function(v, k) {
if (k in _probeSearchObj) {
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v];
} else {
_probeSearchObj[k] = v;
}
});
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
return __renderAppPageLifecycle({
cleanPathname,
clearRequestContext() {
Expand Down Expand Up @@ -13509,7 +13587,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return LayoutComp({ params: _asyncLayoutParams, children: null });
},
probePage() {
return PageComponent({ params });
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
},
revalidateSeconds,
renderErrorBoundaryResponse(renderErr) {
Expand Down
147 changes: 147 additions & 0 deletions tests/app-page-probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { probeAppPageBeforeRender } from "../packages/vinext/src/server/app-page-probe.js";

// Mirrors makeThenableParams() from app-rsc-entry.ts — the function that
// converts raw null-prototype params into objects that work with both
// `await params` (Next.js 15+) and `params.id` (pre-15).
function makeThenableParams<T extends Record<string, unknown>>(obj: T): Promise<T> & T {
const plain = { ...obj } as T;
return Object.assign(Promise.resolve(plain), plain);
}

describe("app page probe helpers", () => {
it("handles layout special errors before probing the page", async () => {
const layoutError = new Error("layout failed");
Expand Down Expand Up @@ -125,6 +133,145 @@ describe("app page probe helpers", () => {
await expect(response?.text()).resolves.toBe("page-fallback");
});

// ── Regression: probePage must receive thenable params/searchParams ──
// probePage() in the generated entry was passing raw null-prototype params
// (from trieMatch) instead of thenable params. Pages using `await params`
// (Next.js 15+ pattern) threw TypeError during probe, causing the probe to
// silently swallow the error instead of detecting notFound()/redirect().

it("detects notFound() from an async-params page when params are thenable", async () => {
const NOT_FOUND_ERROR = new Error("NEXT_NOT_FOUND");
const params = Object.create(null);
params.id = "invalid";

// Simulates a page that does `const { id } = await params; notFound()`
async function AsyncParamsPage(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params;
if (id === "invalid") throw NOT_FOUND_ERROR;
return null;
}

const renderPageSpecialError = vi.fn(
async () => new Response("not-found-fallback", { status: 404 }),
);

// With thenable params, the probe should catch notFound()
const response = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
return AsyncParamsPage({ params: makeThenableParams(params) });
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === NOT_FOUND_ERROR ? { kind: "http-access-fallback", statusCode: 404 } : null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});

expect(renderPageSpecialError).toHaveBeenCalledOnce();
expect(response?.status).toBe(404);
});

it("detects redirect() from an async-searchParams page when searchParams are thenable", async () => {
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");

// Simulates a page that does `const { dest } = await searchParams; redirect(dest)`
async function AsyncSearchPage(props: {
params: Promise<Record<string, unknown>>;
searchParams: Promise<{ dest?: string }>;
}) {
const { dest } = await props.searchParams;
if (dest) throw REDIRECT_ERROR;
return null;
}

const renderPageSpecialError = vi.fn(
async () => new Response(null, { status: 307, headers: { location: "/about" } }),
);

const response = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
return AsyncSearchPage({
params: makeThenableParams({}),
searchParams: makeThenableParams({ dest: "/about" }),
});
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === REDIRECT_ERROR
? { kind: "redirect", location: "/about", statusCode: 307 }
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});

expect(renderPageSpecialError).toHaveBeenCalledOnce();
expect(response?.status).toBe(307);
});

it("probe silently fails when searchParams is omitted and page awaits it", async () => {
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");

// When the old probePage() omitted searchParams, the component received
// undefined for that prop. `await undefined` produces undefined, then
// destructuring undefined throws TypeError. The probe catches it but
// doesn't recognize it as a special error, so it returns null.
const renderPageSpecialError = vi.fn(async () => new Response(null, { status: 307 }));

const response = await probeAppPageBeforeRender({
hasLoadingBoundary: false,
layoutCount: 0,
probeLayoutAt() {
return null;
},
probePage() {
// Simulate what happens at runtime when searchParams is not passed:
// the page component receives no searchParams prop, then tries to
// destructure it after await. This throws TypeError.
return Promise.resolve().then(() => {
throw new TypeError("Cannot destructure property 'dest' of undefined");
});
},
renderLayoutSpecialError() {
throw new Error("unreachable");
},
renderPageSpecialError,
resolveSpecialError(error) {
return error === REDIRECT_ERROR
? { kind: "redirect", location: "/about", statusCode: 307 }
: null;
},
runWithSuppressedHookWarning(probe) {
return probe();
},
});

// The probe catches the TypeError but resolveSpecialError returns null
// for it (TypeError is not a special error) so the probe returns null.
// The redirect is never detected early.
expect(response).toBeNull();
expect(renderPageSpecialError).not.toHaveBeenCalled();
});
Comment on lines +231 to +273
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — explicitly documenting the broken behavior as a test case is a good pattern. This makes it clear what the old code path did and protects against regressions if the probe's error handling ever changes.


it("does not await async page probes when a loading boundary is present", async () => {
const renderPageSpecialError = vi.fn();

Expand Down
Loading
Loading