fix: RSC compatibility for dynamic() and layout segment context#466
Conversation
Three related fixes for React Server Component environments: 1. **dynamic.ts: Remove "use client" and add RSC async path** The `"use client"` directive forced `next/dynamic` into a client component boundary, but `dynamic()` should work in server components too. In the RSC environment, `React.lazy` is not available (the `react-server` condition exports a stripped-down React). Added a runtime check: when `React.lazy` is not a function, use an async server component pattern instead (the RSC renderer natively supports async components). Also switched from destructured imports (`lazy`, `Suspense`, `useState`, `useEffect`) to `React.lazy`, `React.Suspense`, etc. to avoid importing names that don't exist under the `react-server` condition. 2. **layout-segment-context.tsx: Remove "use client"** This module is imported directly by the RSC entry. The `"use client"` directive created a client component boundary that breaks the RSC rendering pipeline. `getLayoutSegmentContext()` already returns `null` when `React.createContext` is unavailable (RSC), and the `LayoutSegmentProvider` gracefully falls back to passing children through unchanged. 3. **app-rsc-entry.ts: Wrap route handler params with makeThenableParams** Next.js 15+ changed route handler params to be async (Promises). Route handlers that `await params` crash when params is a plain object. `makeThenableParams()` wraps the object so it's both a Promise and has synchronous property access.
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
Review: RSC compatibility for dynamic() and layout segment context
Overall this is a solid PR that addresses real issues. The three changes are coherent and well-scoped. A few observations and one suggestion below.
Summary
dynamic.ts — The approach is sound. Removing "use client" and switching from destructured imports to React.* namespace access is the correct fix for RSC compatibility. The runtime typeof React.lazy !== "function" check is a clean way to detect the react-server environment without build-time coupling. The async server component fallback is the right pattern for RSC.
layout-segment-context.tsx — Correct. The module already has a graceful fallback when createContext is unavailable. The "use client" directive was creating an unnecessary component boundary in the RSC entry's module graph. Since the RSC entry directly imports LayoutSegmentProvider, removing the directive lets it run in-process rather than forcing a client reference indirection.
app-rsc-entry.ts — Clear and correct. makeThenableParams() is already used for page params on the same file; extending it to route handlers is the obvious fix for await params in route handlers.
Testing gap
The existing tests in tests/dynamic.test.ts and tests/shims.test.ts run in Node.js where React.lazy is available, so none of them exercise the new AsyncServerDynamic path. Consider adding a test that mocks the RSC environment (e.g., temporarily stubs React.lazy to undefined) to verify the async server component fallback works correctly.
| return React.createElement(Component, props); | ||
| }; | ||
| AsyncServerDynamic.displayName = "DynamicAsyncServer"; | ||
| return AsyncServerDynamic as unknown as ComponentType<P>; |
There was a problem hiding this comment.
The as unknown as ComponentType<P> cast is needed because AsyncServerDynamic is an async function (returns Promise<ReactElement>) which doesn't match the synchronous ComponentType<P> signature. This is fine because the RSC renderer natively supports async components — but it's worth a brief comment explaining why the cast is safe, since it looks suspicious at first glance.
| return AsyncServerDynamic as unknown as ComponentType<P>; | |
| // Cast is safe: async components are natively supported by the RSC renderer, | |
| // but TypeScript's ComponentType<P> doesn't account for async return types. | |
| return AsyncServerDynamic as unknown as ComponentType<P>; |
| // Use an async server component pattern instead — the RSC renderer | ||
| // natively supports async components. | ||
| if (typeof React.lazy !== "function") { | ||
| const AsyncServerDynamic = async (props: P) => { |
There was a problem hiding this comment.
The LoadingComponent option is silently ignored in the RSC async path. This is likely the correct behavior (the RSC stream suspends natively on the async component, and any <Suspense> boundary higher in the tree provides the loading fallback). But it's a behavioral difference from the SSR path where LoadingComponent is rendered as the <Suspense> fallback.
Worth a comment noting this is intentional:
| const AsyncServerDynamic = async (props: P) => { | |
| const AsyncServerDynamic = async (props: P) => { | |
| // Note: LoadingComponent is not used here — in the RSC environment, | |
| // async components suspend natively and parent <Suspense> boundaries | |
| // provide loading states. |
| // Use an async server component pattern instead — the RSC renderer | ||
| // natively supports async components. | ||
| if (typeof React.lazy !== "function") { | ||
| const AsyncServerDynamic = async (props: P) => { |
There was a problem hiding this comment.
nit: This async function doesn't have error handling. If loader() rejects, the error propagates uncaught through the RSC renderer. In the SSR path, getDynamicErrorBoundary() wraps the lazy component so loader failures render the LoadingComponent with the error. The RSC path skips this because React.Component (needed for the error boundary class) is also unavailable under react-server. This is acceptable — RSC errors will be caught by the nearest error boundary in the component tree — but worth being aware of.
|
Review posted successfully. Here's a summary of the feedback: Review SummaryThe PR addresses three real issues with RSC compatibility and the changes are well-scoped. All three fixes are correct:
Inline comments:
Testing gap:
|
|
@benfavre I need this fixed for my app. Will you continue working on this? |
…t-segment # Conflicts: # packages/vinext/src/entries/app-rsc-entry.ts
…dLayoutSegment(s) Removing 'use client' caused LayoutSegmentProvider to run in the RSC environment where React.createContext is undefined. getLayoutSegmentContext() returned null, the provider became a no-op, and useSelectedLayoutSegments always returned [] instead of the actual segments. The 'use client' boundary is required so the RSC bundler renders this component in the SSR/browser environment where createContext works.
…params Add two regression tests for the fixes in PR cloudflare#466: 1. Route handler await params (Next.js 15 async params pattern): Tests /api/catch-all/[...slugs] which uses `await params` in the handler — verifies that makeThenableParams() correctly wraps params so both await and direct property access work. 2. dynamic() in RSC (async component path): Adds a pure server component fixture that uses dynamic() without "use client". In the RSC environment React.lazy is unavailable, so dynamic() must fall back to the async component pattern. Tests that the dynamically-loaded component renders in the HTML output.
|
/bigbonk review |
There was a problem hiding this comment.
Review: RSC compatibility for dynamic() and layout segment context
All three fixes are correct and well-scoped. CI is fully green. A few issues to address before merging.
PR description vs actual diff — layout-segment-context.tsx
The PR description repeatedly states "use client" was removed from layout-segment-context.tsx, but the diff shows it was kept and the comment was updated to explain why it's needed. The description's "Problem" section (#2), "Solution" section, "Changes" table, and "Risk" section all claim the directive was removed. This is misleading — please update the PR description to match what the code actually does.
To be clear: keeping "use client" is the correct decision. Without it, React.createContext is undefined in the RSC environment, getLayoutSegmentContext() returns null, and useSelectedLayoutSegment(s) always returns [] for client components. The updated comment in the file accurately explains this. The PR description just needs to match.
dynamic.ts — the RSC fallback path
The approach is sound. Removing "use client" is correct because dynamic() needs to execute in the RSC module graph to perform its environment detection. If it had "use client", the RSC plugin would replace it with a client reference proxy, and dynamic() calls in server components would never reach the async component fallback.
One architectural subtlety worth noting: with React 19.x, React.lazy IS exported from the react-server condition. The typeof React.lazy !== "function" guard may not actually trigger in current React versions — React.lazy exists and works in the RSC environment. This means the new AsyncServerDynamic path might be dead code today, with the existing LazyServer path handling RSC correctly. That said, the guard is defensive and correct — if a future React version strips lazy from react-server, this fallback catches it. But the test fixture may be passing because of the existing React.lazy + Suspense SSR path, not the new async path. Worth verifying.
app-rsc-entry.ts — route handler params
Clean fix. makeThenableParams() is already used for page params, layout params, metadata params, and slot params in the same generated entry. Route handler params were the only gap. The catch-all fixture and tests are well-written.
Testing
The new test fixtures and cases cover the happy paths well. The previous review correctly noted that the AsyncServerDynamic codepath is difficult to exercise in Node.js tests where React.lazy is available. Given the subtlety above about React.lazy being available in react-server, it's worth confirming the RSC dynamic test actually hits the new code path vs the existing lazy path.
| // exports a stripped-down React without lazy/useState/useEffect). | ||
| // Use an async server component pattern instead — the RSC renderer | ||
| // natively supports async components. | ||
| if (typeof React.lazy !== "function") { |
There was a problem hiding this comment.
This guard may not trigger with current React 19.x — React.lazy is exported from the react-server condition entry (react.react-server.production.js exports lazy at line ~396). If React.lazy IS a function in the RSC environment, this branch is dead code and the existing LazyServer path (lines 159-180) handles RSC rendering via React.lazy + React.Suspense.
This isn't necessarily a problem — the guard is defensive and correct for forward compatibility. But it means:
- The regression test at
/nextjs-compat/dynamic/rsc-dynamicmay pass because of the existingLazyServerpath, not this new async path. - If you want to verify this path works, you'd need a test that stubs
React.lazytoundefined.
Could you confirm whether this branch actually executes in the test? A console.log or debugger would tell you. If it doesn't, consider adding a unit test with a mocked React.lazy = undefined to cover it.
| // Use an async server component pattern instead — the RSC renderer | ||
| // natively supports async components. | ||
| if (typeof React.lazy !== "function") { | ||
| const AsyncServerDynamic = async (props: P) => { |
There was a problem hiding this comment.
The previous review flagged that loader rejections in this async path propagate uncaught (no error boundary wrapping). That's acceptable since RSC errors propagate to the nearest error boundary in the component tree. But the LoadingComponent option is silently ignored — if a caller passes { loading: () => <Spinner /> }, they won't see it in RSC. Worth a comment:
| const AsyncServerDynamic = async (props: P) => { | |
| const AsyncServerDynamic = async (props: P) => { | |
| // Note: LoadingComponent is not used here — in the RSC environment, | |
| // async components suspend natively and parent <Suspense> boundaries | |
| // provide loading states. Error handling also defers to the nearest | |
| // error boundary in the component tree. |
| return React.createElement(Component, props); | ||
| }; | ||
| AsyncServerDynamic.displayName = "DynamicAsyncServer"; | ||
| return AsyncServerDynamic as unknown as ComponentType<P>; |
There was a problem hiding this comment.
The previous review suggested a comment on this cast. Agree it's worth it — the as unknown as ComponentType<P> is suspicious without context:
| return AsyncServerDynamic as unknown as ComponentType<P>; | |
| // Cast is safe: async components are natively supported by the RSC renderer, | |
| // but TypeScript's ComponentType<P> doesn't account for async return types. | |
| return AsyncServerDynamic as unknown as ComponentType<P>; |
| * | ||
| * This is a "use client" module because it needs React's createContext | ||
| * and useContext, which are NOT available in the react-server condition. | ||
| * The RSC entry renders this as a client component boundary. | ||
| * Must be "use client" so that Vite's RSC bundler renders this component in | ||
| * the SSR/browser environment where React.createContext is available. The RSC | ||
| * entry imports and renders LayoutSegmentProvider directly, but because of the | ||
| * "use client" boundary the actual execution happens on the SSR/client side | ||
| * where the context can be created and consumed by useSelectedLayoutSegment(s). | ||
| * | ||
| * Without "use client", this runs in the RSC environment where | ||
| * React.createContext is undefined, getLayoutSegmentContext() returns null, | ||
| * the provider becomes a no-op, and useSelectedLayoutSegments always returns []. | ||
| * | ||
| * The context is shared with navigation.ts via getLayoutSegmentContext() | ||
| * to avoid creating separate contexts in different modules. |
There was a problem hiding this comment.
The updated comment is accurate and well-written. However, the PR description says "use client" was removed from this file, when in reality it was kept (correctly). The description's Problem section (#2), Solution section, Changes table, and Risk section all claim removal. Please update the PR description to match the actual change — which is a comment rewrite explaining why the directive is necessary.
| const data = await res.json(); | ||
| expect(data.slugs).toEqual(["foo-bar", "baz-qux"]); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Good catch removing this from the "documented skips" list now that the fixture exists.
|
Review posted successfully. Here's a summary of the feedback: Key findings
|
- Add comments explaining LoadingComponent is intentionally ignored in the RSC async fallback path (parent Suspense boundaries handle loading states; error handling defers to nearest error boundary) - Add comment explaining the 'as unknown as ComponentType<P>' cast is safe because the RSC renderer natively supports async components, but TypeScript's ComponentType<P> doesn't account for async return types - Add unit tests for the AsyncServerDynamic path by stubbing React.lazy to undefined (simulating a react-server condition without lazy). Verifies: displayName, async resolution, bare component exports, and that LoadingComponent is correctly ignored. Note: React 19.x exports React.lazy from the react-server condition, so typeof React.lazy !== 'function' does not trigger in current React. The AsyncServerDynamic path is defensive forward-compatibility code. The unit tests stub React.lazy to exercise it directly.
|
/bigbonk review |
There was a problem hiding this comment.
Review
All three fixes are correct. The app-rsc-entry.ts and layout-segment-context.tsx changes are clean and low-risk. The dynamic.ts refactor is well-structured. CI is green. Two items to address before merging.
PR description is inaccurate (flagged twice already, still not fixed)
Two prior reviews have pointed out that the PR description claims "use client" was removed from layout-segment-context.tsx, when the diff shows it was kept (correctly) and only the comment was updated. The Problem section, Solution section, Changes table, and Risk section all still say removal. This has been flagged since March 11th. Please update the description — it's misleading for anyone triaging this later.
ssr: false path — LoadingComponent in RSC needs the same React.createElement availability check
When ssr: false and isServer is true (line 96-104), the SSRFalse component calls React.createElement(LoadingComponent, ...). In the RSC environment, React.createElement IS available (it's one of the APIs exported from react-server), so this works. But the comment says "On the server (SSR or RSC)" which is good — just confirming this path is safe.
Tests are solid
The unit tests in dynamic.test.ts that stub React.lazy = undefined are a good approach for covering the defensive fallback path. The integration test via the rsc-dynamic fixture covers the real RSC codepath. The catch-all route handler test is clean and well-referenced to Next.js sources.
Summary
- Fix the PR description to match reality on
layout-segment-context.tsx - Consider the minor comment nit on the fixture file (see inline)
- Otherwise LGTM
| // Regression test for: https://github.com/cloudflare/vinext/pull/466 | ||
| // | ||
| // In the RSC environment, React.lazy is not available (react-server condition | ||
| // strips it). dynamic() must fall back to the async component pattern so that |
There was a problem hiding this comment.
This comment is inaccurate for current React 19.x. React.lazy IS exported from the react-server condition — the typeof React.lazy !== "function" guard in dynamic.ts does not trigger today. The RSC test passes because React.lazy + React.Suspense work fine in the RSC environment (the existing LazyServer path handles it).
The AsyncServerDynamic path is defensive forward-compatibility code. The fixture comment should reflect this rather than stating as fact that React.lazy is stripped.
| // strips it). dynamic() must fall back to the async component pattern so that | |
| // In the RSC environment, React.lazy may not be available in future React | |
| // versions (the react-server condition could strip it). dynamic() has a | |
| // defensive fallback to an async component pattern for that scenario. |
| // Regression test for: https://github.com/cloudflare/vinext/pull/466 | ||
| // | ||
| // Verifies that dynamic() works in a pure RSC context where React.lazy is | ||
| // unavailable. The dynamic() shim falls back to an async component so that |
There was a problem hiding this comment.
Same inaccuracy — React.lazy is not currently unavailable in the RSC environment. This fixture exercises dynamic() in RSC, but in practice it hits the LazyServer path (not the AsyncServerDynamic fallback). The unit test with React.lazy = undefined is the one that actually covers the new code path.
| // unavailable. The dynamic() shim falls back to an async component so that | |
| // Verifies that dynamic() works in a pure RSC context. Currently React.lazy | |
| // is available in react-server, so the standard lazy path handles this. | |
| // The async fallback path (for future React versions that strip lazy from | |
| // react-server) is tested in tests/dynamic.test.ts via React.lazy stubbing. |
tests/nextjs-compat/dynamic.test.ts
Outdated
|
|
||
| // Regression test for: https://github.com/cloudflare/vinext/pull/466 | ||
| // | ||
| // In the RSC environment, React.lazy is not available (react-server condition |
There was a problem hiding this comment.
Same as the fixture comments — this claim is inaccurate for React 19.x. React.lazy IS available under the react-server condition. This test exercises dynamic() in a real RSC render, but it passes via the existing LazyServer + React.Suspense path, not the new AsyncServerDynamic fallback.
The test is still valuable — it's a regression test confirming dynamic() works in server components. But the comment explaining why it works is wrong.
| // In the RSC environment, React.lazy is not available (react-server condition | |
| // Regression test for: https://github.com/cloudflare/vinext/pull/466 | |
| // | |
| // Verifies that dynamic() works when called from a pure server component. | |
| // In React 19.x, React.lazy IS available in the react-server condition, | |
| // so this exercises the standard LazyServer + Suspense path in RSC. | |
| // The AsyncServerDynamic fallback (for hypothetical future React versions | |
| // that strip lazy) is covered by unit tests in tests/dynamic.test.ts. |
packages/vinext/src/shims/dynamic.ts
Outdated
| * available. On the client, also uses React.lazy for code splitting. | ||
| * | ||
| * Works in RSC, SSR, and client environments: | ||
| * - RSC: React.lazy is not available, so we use an async component pattern |
There was a problem hiding this comment.
This top-level doc comment states "RSC: React.lazy is not available" as fact, but React 19.x DOES export React.lazy from the react-server condition. The RSC path works today via the standard LazyServer branch. The AsyncServerDynamic path is a defensive forward-compat measure.
| * - RSC: React.lazy is not available, so we use an async component pattern | |
| * Works in RSC, SSR, and client environments: | |
| * - RSC: Uses React.lazy + Suspense (available in React 19.x react-server). | |
| * Falls back to async component pattern if a future React version | |
| * strips lazy from react-server. | |
| * - SSR: React.lazy + Suspense (renderToReadableStream suspends) | |
| * - Client: React.lazy + Suspense (standard code splitting) |
| // exports a stripped-down React without lazy/useState/useEffect). | ||
| // Use an async server component pattern instead — the RSC renderer | ||
| // natively supports async components. | ||
| if (typeof React.lazy !== "function") { |
There was a problem hiding this comment.
The two prior reviews both flagged this and it hasn't been addressed: in React 19.x, React.lazy IS a function in the react-server condition, so this guard never triggers. The AsyncServerDynamic path is dead code today.
This is fine as defensive forward-compat code, but the comment should say so explicitly. The current comment states definitively that react-server doesn't have lazy, which is incorrect for the React version this project actually uses.
| if (typeof React.lazy !== "function") { | |
| // Defensive fallback: if a future React version strips React.lazy from the | |
| // react-server condition, fall back to an async component pattern. | |
| // In React 19.x, React.lazy IS available in react-server, so this branch | |
| // does not execute — it exists for forward compatibility only. | |
| if (typeof React.lazy !== "function") { |
|
Review posted successfully. Here's a summary: Review SummaryAll three fixes are correct and CI is green. Two items to address:
The actual code changes are sound:
|
React 19.x exports React.lazy from the react-server condition, so the typeof React.lazy !== 'function' guard does not trigger in current React. The AsyncServerDynamic path is defensive forward-compatibility code only — it does not execute today. Update all comments that incorrectly claimed React.lazy is unavailable in the react-server environment: - dynamic.ts: top-level doc comment and guard comment - dynamic-rsc.tsx fixture: comment explaining the RSC path - rsc-dynamic/page.tsx fixture: explain which path actually executes - nextjs-compat/dynamic.test.ts: clarify that the integration test exercises LazyServer + Suspense, not AsyncServerDynamic
|
/bigbonk review |
There was a problem hiding this comment.
Review
All three fixes are correct and well-motivated. CI is green (one integration shard still pending but the rest pass). The code changes are sound, tests are thorough, and the author has addressed the prior review feedback on comments/docs (the code now accurately describes the AsyncServerDynamic path as forward-compat). Two items remain before this should merge.
PR description is still inaccurate
Three prior reviews have flagged this: the PR description claims "use client" was removed from layout-segment-context.tsx, but the diff shows it was kept (correctly) with an updated comment. The Problem section, Solution section, Changes table, and Risk section all still say removal. This is the fourth time it's being raised — please update the description to match reality.
Code assessment
dynamic.ts — Correct approach. Removing "use client" is necessary because otherwise the RSC plugin replaces the module with a client reference proxy and dynamic() calls in server components never reach the actual implementation. The switch from destructured imports to React.* namespace access is the right pattern — it avoids silent undefined values when APIs are absent under react-server. The AsyncServerDynamic defensive fallback is clean dead code today (React 19.x does export React.lazy from react-server), but it's correctly guarded and well-documented as forward-compat. The unit tests that stub React.lazy = undefined are the right way to cover that path.
app-rsc-entry.ts — One-line fix, obviously correct. makeThenableParams() was already used for page params, layout params, metadata params, and slot params in the same file. Route handler params were the only gap.
layout-segment-context.tsx — Comment-only change. The updated comment accurately explains why "use client" is required.
One concern on the ssr: false + RSC path
See inline comment below.
| // On the server (SSR or RSC), just render the loading state or nothing | ||
| const SSRFalse = (_props: P) => { | ||
| return LoadingComponent | ||
| ? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null }) |
There was a problem hiding this comment.
Minor concern: when dynamic(() => import('./Foo'), { ssr: false }) is called in a server component, this renders LoadingComponent via React.createElement. In the RSC environment, React.createElement is available, but if LoadingComponent is a client component (common for spinners with CSS animations), this will work because the RSC plugin handles client references during rendering.
However, if LoadingComponent itself uses hooks like useState/useEffect (which are unavailable in RSC), this would crash. This is an edge case — most loading spinners are pure presentational — and it matches Next.js behavior (Next.js also renders the loading component on the server). Just flagging it for awareness; no action needed.
There was a problem hiding this comment.
Acknowledged — this matches Next.js behavior and the edge case is inherently user error (passing a hook-using component as loading in a server component context). The common case (presentational spinners, static JSX) works correctly. No action taken per your note.
| // react-server condition, fall back to an async component pattern. | ||
| // In React 19.x, React.lazy IS available in react-server, so this branch | ||
| // does not execute — it exists for forward compatibility only. | ||
| if (typeof React.lazy !== "function") { |
There was a problem hiding this comment.
The comments here are now accurate — good job addressing the prior review feedback. The guard, the AsyncServerDynamic implementation, and the cast comment all correctly describe this as forward-compat code.
One nit: the getDynamicErrorBoundary() check at line 180 also handles the RSC case where React.Component is unavailable (it returns null and the error boundary is skipped). This is consistent with the AsyncServerDynamic path's approach of deferring error handling to the nearest error boundary. Worth noting that both paths degrade gracefully in RSC.
|
Review posted successfully. Here's a summary: All three fixes are correct. CI is green. The code is well-structured and the tests are thorough. Key findings:
|
|
/bigbonk re-review this pr? |
There was a problem hiding this comment.
Review
All three fixes are correct, well-motivated, and CI is fully green. The code is clean and the comments now accurately describe the AsyncServerDynamic path as forward-compat only. Good test coverage with both unit tests (stubbing React.lazy) and integration tests (RSC fixture).
Summary
-
dynamic.ts— Removing"use client"is the correct fix. Without it, the RSC plugin replaces the module with a client reference proxy anddynamic()calls in server components never reach the actual implementation. The namespace access pattern (React.lazyvs destructuredlazy) correctly avoids silentundefinedvalues. The defensiveAsyncServerDynamicfallback is clean dead code today but well-guarded and well-documented. -
app-rsc-entry.ts— One-line fix, obviously correct.makeThenableParams()was already used for every other params context in the same generated entry; route handlers were the only gap. No dev/prod parity concern since the App Router prod server delegates to the built RSC entry. -
layout-segment-context.tsx— Comment-only change,"use client"correctly retained. The updated comment accurately explains the requirement.
Outstanding issue: PR description is inaccurate
This has been flagged in all four prior reviews and remains unaddressed. The PR description claims "use client" was removed from layout-segment-context.tsx in multiple places (Problem section item #2 mentions removing it, the Note section was added but the Problem section still implies removal). The diff shows it was kept (correctly) with only the comment updated. Please update the description to match reality — it's the primary documentation future readers will see when bisecting.
Code is ready to merge
The code itself is sound. The only blocker is the PR description inaccuracy — once that's corrected, this should be good to go.
| * - dynamic(() => import('./Component'), { ssr: false }) | ||
| */ | ||
| import React, { lazy, Suspense, type ComponentType, useState, useEffect } from "react"; | ||
| import React, { type ComponentType } from "react"; |
There was a problem hiding this comment.
Clean change. Switching from destructured { lazy, Suspense, useState, useEffect } to a single React namespace import and accessing them as React.lazy, React.Suspense, etc. is the right approach. The destructured form would bind undefined at import time in environments where those APIs are absent, with no way to detect it later. The namespace form lets the runtime typeof checks work correctly.
|
|
||
| const DynamicRsc = dynamic(() => Promise.resolve({ default: Hello })); | ||
| // The returned component is an async function — call it directly as RSC would | ||
| const element = await (DynamicRsc as unknown as (props: object) => Promise<unknown>)({}); |
There was a problem hiding this comment.
Good test. Calling the async component directly and asserting on the returned element type is the right way to verify this path, since RSC renderers invoke async components the same way.
| method, | ||
| middlewareContext: _mwCtx, | ||
| params, | ||
| params: makeThenableParams(params), |
There was a problem hiding this comment.
This was the only place in the generated entry where params wasn't wrapped. makeThenableParams() is already applied to page params (line ~1776), layout params (line ~1903), metadata params, and slot params. Good catch filling the gap.
|
Review posted successfully. Here's a summary: All three fixes are correct and CI is fully green.
One outstanding issue: The PR description still claims |
Problem
Two related issues prevent
next/dynamicfrom working correctly in React Server Component environments, plus a missing feature for Next.js 15+ route handlers:1.
dynamic.tscrashes in RSC withReact.lazy is not a functionThe
"use client"directive forcesnext/dynamicinto a client component boundary, butdynamic()should work in server components too. When the RSC entry imports it, thereact-servercondition exports a stripped-down React that does not includeuseStateoruseEffect. The destructured imports fail silently (they becomeundefined). Additionally, as a defensive forward-compatibility measure, a runtime check fortypeof React.lazy !== "function"is added — if a future React version stripslazyfromreact-server, the async server component fallback catches it (the RSC renderer natively supports async components).2. Route handler params not wrapped as thenable
Next.js 15+ changed route handler
paramsto be async (Promises). Route handlers thatawait paramscrash when params is a plain object frommatchPattern(). The RSC entry already hasmakeThenableParams()for this purpose but wasn't using it for route handler params.Note on
layout-segment-context.tsxThe
"use client"directive was kept in this file (it was not removed). Only the comment was updated to explain why the directive is required:LayoutSegmentProvideris imported directly by the RSC entry, and the"use client"boundary ensures it executes in the SSR/browser environment whereReact.createContextis available. Without it,getLayoutSegmentContext()returnsnullanduseSelectedLayoutSegmentsalways returns[].Solution
dynamic.ts
"use client"directive sodynamic()executes in the RSC module graph and can perform environment detectionlazy,Suspense,useState,useEffect) to namespace access (React.lazy,React.Suspense, etc.) — these are safely checked at runtimetypeof React.lazy !== "function", use an async server component pattern (the RSC renderer natively supports async components). In React 19.x,React.lazyis available inreact-serverso this path is forward-compatibility code.layout-segment-context.tsx
"use client"is required and what happens without itapp-rsc-entry.ts
paramswithmakeThenableParams()soawait paramsworks correctlyChanges
packages/vinext/src/shims/dynamic.ts"use client", add RSC async fallback path, useReact.*namespacepackages/vinext/src/shims/layout-segment-context.tsx"use client"is requiredpackages/vinext/src/entries/app-rsc-entry.ts{ params }→{ params: makeThenableParams(params) }Reproduction
Risk
Low-medium.
React.lazyis unavailable, which doesn't occur in React 19.x). SSR and client paths are functionally identical — only the import style changed from destructured to namespace."use client"retained as required.makeThenableParams()is already used for page params, layout params, metadata params, and slot params in the same file; this extends it to route handlers.