From 4816eceee0ae5058ecc4a2db9413d7d0f9d96d9f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:57:21 +1100 Subject: [PATCH] fix: pass thenable params and searchParams to probePage() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probePage() passed raw null-prototype params instead of thenable params, causing TypeError for any page using the Next.js 15+ async params pattern (await params). The probe also omitted searchParams entirely. Both issues caused the probe to silently fail, defeating its purpose of early notFound() and redirect() detection. The layout probe already used the correct thenable params — the page probe was the only inconsistent path. --- packages/vinext/src/entries/app-rsc-entry.ts | 15 +- .../entry-templates.test.ts.snap | 90 ++++++++++- tests/app-page-probe.test.ts | 147 ++++++++++++++++++ tests/app-router.test.ts | 39 +++++ .../app/probe-async-params/[id]/page.tsx | 23 +++ .../app-basic/app/probe-async-search/page.tsx | 21 +++ 6 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/app-basic/app/probe-async-params/[id]/page.tsx create mode 100644 tests/fixtures/app-basic/app/probe-async-search/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ec..1d0d4163 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -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); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -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) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 7c8a503e..1cdaec57 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -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() { @@ -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) { @@ -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() { @@ -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) { @@ -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() { @@ -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) { @@ -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() { @@ -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) { @@ -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() { @@ -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) { @@ -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() { @@ -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) { diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 563938ff..052f51a7 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -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>(obj: T): Promise & 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"); @@ -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>; + 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(); + }); + it("does not await async page probes when a loading boundary is present", async () => { const renderPageSpecialError = vi.fn(); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 29871a2a..9bbd37b0 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -625,6 +625,45 @@ describe("App Router integration", () => { expect(location).toContain("/about"); }); + // ── probePage() with Next.js 15+ async params/searchParams ── + // Regression tests: probePage() passed raw null-prototype params instead of + // thenable params, so pages using `await params` threw TypeError during probe, + // silently defeating early notFound()/redirect() detection. + + it("notFound() detected via probe when page uses async params pattern", async () => { + // Page does `const { id } = await params` then calls notFound() for invalid IDs. + // Without thenable params, `await params` throws TypeError → probe silently fails + // → notFound() is caught during RSC render instead of the probe → still returns + // 404 but only by luck of error boundary handling, not the probe path. + const res = await fetch(`${baseUrl}/probe-async-params/invalid-id`); + expect(res.status).toBe(404); + const html = await res.text(); + // Should render root not-found boundary, not the page content + expect(html).not.toContain("probe-async-params-page"); + }); + + it("page renders normally with async params when ID is valid", async () => { + const { res, html } = await fetchHtml(baseUrl, "/probe-async-params/valid-1"); + expect(res.status).toBe(200); + expect(html).toContain("probe-async-params-page"); + }); + + it("redirect() detected via probe when page uses async searchParams pattern", async () => { + // Page does `const { dest } = await searchParams` then calls redirect(dest). + // Without searchParams in the probe, `await searchParams` throws TypeError → + // probe silently fails → redirect() goes through RSC render path instead. + const res = await fetch(`${baseUrl}/probe-async-search?dest=/about`, { redirect: "manual" }); + expect(res.status).toBeGreaterThanOrEqual(300); + expect(res.status).toBeLessThan(400); + expect(res.headers.get("location")).toContain("/about"); + }); + + it("page renders normally with async searchParams when no dest param", async () => { + const { res, html } = await fetchHtml(baseUrl, "/probe-async-search"); + expect(res.status).toBe(200); + expect(html).toContain("probe-async-search-page"); + }); + it("permanentRedirect() returns 308 status code", async () => { const res = await fetch(`${baseUrl}/permanent-redirect-test`, { redirect: "manual" }); expect(res.status).toBe(308); diff --git a/tests/fixtures/app-basic/app/probe-async-params/[id]/page.tsx b/tests/fixtures/app-basic/app/probe-async-params/[id]/page.tsx new file mode 100644 index 00000000..cd3808f4 --- /dev/null +++ b/tests/fixtures/app-basic/app/probe-async-params/[id]/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +const VALID_IDS = ["valid-1", "valid-2"]; + +// Uses the Next.js 15+ async params pattern: destructures after await. +// If probePage() passes raw null-prototype params instead of thenable params, +// `await params` throws TypeError and the probe silently fails, meaning +// notFound() is never detected early. +export default async function ProbeAsyncParamsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + if (!VALID_IDS.includes(id)) { + notFound(); + } + + return

Probe async params: {id}

; +} diff --git a/tests/fixtures/app-basic/app/probe-async-search/page.tsx b/tests/fixtures/app-basic/app/probe-async-search/page.tsx new file mode 100644 index 00000000..45acd347 --- /dev/null +++ b/tests/fixtures/app-basic/app/probe-async-search/page.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +// Uses the Next.js 15+ async searchParams pattern: destructures after await. +// If probePage() omits searchParams entirely, `await searchParams` throws +// TypeError and the probe silently fails, meaning redirect() is never +// detected early. +export default async function ProbeAsyncSearchPage({ + searchParams, +}: { + searchParams: Promise<{ dest?: string }>; +}) { + const { dest } = await searchParams; + + if (dest) { + redirect(dest); + } + + return

No redirect destination

; +}