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

; +}