-
Notifications
You must be signed in to change notification settings - Fork 292
fix: pass thenable params and searchParams to probePage() #763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
james-elicx
merged 1 commit into
cloudflare:main
from
NathanDrake2406:fix/probe-page-thenable-params
Apr 3, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
|
||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 thehasSearchParamstracking. If you wanted to reduce the duplication you could extract a smallsearchParamsToObject(urlSearchParams)helper at the top of the generated entry (next tomakeThenableParams), 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.