Skip to content

fix: RSC compatibility for dynamic() and layout segment context#466

Merged
james-elicx merged 8 commits intocloudflare:mainfrom
benfavre:fix/rsc-dynamic-layout-segment
Mar 28, 2026
Merged

fix: RSC compatibility for dynamic() and layout segment context#466
james-elicx merged 8 commits intocloudflare:mainfrom
benfavre:fix/rsc-dynamic-layout-segment

Conversation

@benfavre
Copy link
Copy Markdown
Contributor

@benfavre benfavre commented Mar 11, 2026

Problem

Two related issues prevent next/dynamic from working correctly in React Server Component environments, plus a missing feature for Next.js 15+ route handlers:

1. dynamic.ts crashes in RSC with React.lazy is not a function

The "use client" directive forces next/dynamic into a client component boundary, but dynamic() should work in server components too. When the RSC entry imports it, the react-server condition exports a stripped-down React that does not include useState or useEffect. The destructured imports fail silently (they become undefined). Additionally, as a defensive forward-compatibility measure, a runtime check for typeof React.lazy !== "function" is added — if a future React version strips lazy from react-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 params to be async (Promises). Route handlers that await params crash when params is a plain object from matchPattern(). The RSC entry already has makeThenableParams() for this purpose but wasn't using it for route handler params.

Note on layout-segment-context.tsx

The "use client" directive was kept in this file (it was not removed). Only the comment was updated to explain why the directive is required: LayoutSegmentProvider is imported directly by the RSC entry, and the "use client" boundary ensures it executes in the SSR/browser environment where React.createContext is available. Without it, getLayoutSegmentContext() returns null and useSelectedLayoutSegments always returns [].

Solution

dynamic.ts

  • Removed "use client" directive so dynamic() executes in the RSC module graph and can perform environment detection
  • Changed destructured imports (lazy, Suspense, useState, useEffect) to namespace access (React.lazy, React.Suspense, etc.) — these are safely checked at runtime
  • Added defensive RSC fallback: when typeof React.lazy !== "function", use an async server component pattern (the RSC renderer natively supports async components). In React 19.x, React.lazy is available in react-server so this path is forward-compatibility code.

layout-segment-context.tsx

  • Updated doc comment to explain why "use client" is required and what happens without it

app-rsc-entry.ts

  • Wrapped route handler params with makeThenableParams() so await params works correctly

Changes

File Change
packages/vinext/src/shims/dynamic.ts Remove "use client", add RSC async fallback path, use React.* namespace
packages/vinext/src/shims/layout-segment-context.tsx Update doc comment explaining why "use client" is required
packages/vinext/src/entries/app-rsc-entry.ts { params }{ params: makeThenableParams(params) }

Reproduction

// page.tsx (Server Component)
import dynamic from "next/dynamic";

// This crashed because dynamic.ts had "use client" preventing RSC environment detection
const Chart = dynamic(() => import("./Chart"), { ssr: false });

export default function Page() {
  return <Chart />;
}

// app/api/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params; // Crashed: params was a plain object, not a Promise
  return Response.json({ id });
}

Risk

Low-medium.

  • dynamic.ts: The RSC async fallback path is defensive (only activates when React.lazy is 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.
  • layout-segment-context.tsx: Comment-only change. "use client" retained as required.
  • app-rsc-entry.ts: makeThenableParams() is already used for page params, layout params, metadata params, and slot params in the same file; this extends it to route handlers.

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.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@466

commit: 5bedf20

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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>;
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.

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.

Suggested change
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) => {
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.

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:

Suggested change
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) => {
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 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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 11, 2026

Review posted successfully. Here's a summary of the feedback:

Review Summary

The PR addresses three real issues with RSC compatibility and the changes are well-scoped. All three fixes are correct:

  1. dynamic.ts — Removing "use client" and using React.* namespace access is the right approach. The typeof React.lazy !== "function" runtime check cleanly detects the react-server environment. The async server component fallback is the correct RSC pattern.

  2. layout-segment-context.tsx — The "use client" directive was unnecessary since the module already gracefully falls back when createContext is unavailable in RSC.

  3. app-rsc-entry.ts — Wrapping route handler params with makeThenableParams() is the obvious fix, consistent with how page params are already handled.

Inline comments:

  • Suggested adding a comment on the as unknown as ComponentType<P> cast explaining why it's safe (async components are natively supported by RSC)
  • Noted that LoadingComponent is silently ignored in the RSC async path — suggested a comment documenting this is intentional
  • Flagged that the RSC async path lacks error handling for loader rejections (acceptable since RSC errors propagate to the nearest error boundary, but worth being aware of)

Testing gap:

  • None of the existing tests exercise the new AsyncServerDynamic path since they run in Node.js where React.lazy is available. Suggested adding a test that mocks the RSC environment.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@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.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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") {
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.

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:

  1. The regression test at /nextjs-compat/dynamic/rsc-dynamic may pass because of the existing LazyServer path, not this new async path.
  2. If you want to verify this path works, you'd need a test that stubs React.lazy to undefined.

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) => {
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.

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:

Suggested change
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>;
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.

The previous review suggested a comment on this cast. Agree it's worth it — the as unknown as ComponentType<P> is suspicious without context:

Suggested change
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>;

Comment on lines 5 to 17
*
* 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.
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.

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"]);
});

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.

Good catch removing this from the "documented skips" list now that the fixture exists.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review posted successfully. Here's a summary of the feedback:

Key findings

  1. PR description is inaccurate for layout-segment-context.tsx. The description says "use client" was removed, but the diff shows it was kept (correctly) and only the comment was updated. The description needs to be corrected in the Problem section, Solution section, Changes table, and Risk section.

  2. dynamic.ts — the AsyncServerDynamic path may be dead code today. React 19.x exports React.lazy from the react-server condition, so the typeof React.lazy !== "function" guard likely doesn't trigger. The test passes because the existing LazyServer + React.Suspense path handles RSC correctly. The guard is still good as defensive code for forward compatibility, but the author should verify which codepath actually runs and consider adding a unit test with React.lazy stubbed to undefined to exercise the new path.

  3. app-rsc-entry.ts route handler params fix is clean and correct. makeThenableParams() was already used for every other params context in the same file; route handlers were the only gap.

  4. Suggestions from the previous review are still unaddressed — comments explaining the as unknown as ComponentType<P> cast and the intentional LoadingComponent omission in the RSC path.

  5. All CI checks pass.

github run

- 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.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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
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.

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.

Suggested change
// 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
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.

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.

Suggested change
// 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.


// Regression test for: https://github.com/cloudflare/vinext/pull/466
//
// In the RSC environment, React.lazy is not available (react-server condition
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.

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.

Suggested change
// 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.

* 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
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.

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.

Suggested change
* - 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") {
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.

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.

Suggested change
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") {

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review posted successfully. Here's a summary:

Review Summary

All three fixes are correct and CI is green. Two items to address:

  1. PR description still inaccurate — Two prior reviews flagged that the description claims "use client" was removed from layout-segment-context.tsx, but the diff shows it was kept (correctly). This has been unaddressed since March 11th.

  2. Comments throughout overstate the RSC incompatibility — Multiple comments in dynamic.ts, test files, and fixture files state that React.lazy is "not available" in the react-server condition, but React 19.x DOES export React.lazy from react-server. The AsyncServerDynamic path is dead code today — it's defensive forward-compat code for hypothetical future React versions. I posted suggestion comments updating the doc comments to accurately reflect this.

The actual code changes are sound:

  • dynamic.ts: Removing "use client", namespace access pattern, and the defensive RSC fallback are all correct
  • app-rsc-entry.ts: makeThenableParams() for route handler params is the obvious fix, consistent with all other param contexts in the same file
  • layout-segment-context.tsx: Comment-only change, "use client" correctly retained

github run

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
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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 })
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.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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") {
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.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

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:

  1. PR description still inaccurate — This is the fourth time reviewers have flagged that the description claims "use client" was removed from layout-segment-context.tsx, when the diff shows it was kept (correctly). The author needs to update the Problem, Solution, Changes, and Risk sections.

  2. dynamic.ts — Correct approach. Removing "use client" is necessary so the RSC plugin doesn't replace the module with a client reference proxy. The namespace access pattern (React.lazy instead of destructured lazy) prevents silent undefined values. The AsyncServerDynamic fallback is well-documented dead code today (React 19.x exports React.lazy from react-server), with good unit test coverage via React.lazy = undefined stubbing.

  3. app-rsc-entry.ts — One-line fix, obviously correct. makeThenableParams() was already used for every other params context in the same file; route handlers were the only gap. No dev/prod parity issue since the App Router prod server delegates to the built RSC entry.

  4. layout-segment-context.tsx — Comment-only change, accurately explains why "use client" is required.

  5. One edge case flagged — The ssr: false + RSC path renders LoadingComponent via React.createElement. If LoadingComponent uses hooks, it would crash in RSC. This matches Next.js behavior and most loading spinners are pure presentational, so it's just an awareness note.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review this pr?

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

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

  1. dynamic.ts — Removing "use client" is the correct fix. Without it, the RSC plugin replaces the module with a client reference proxy and dynamic() calls in server components never reach the actual implementation. The namespace access pattern (React.lazy vs destructured lazy) correctly avoids silent undefined values. The defensive AsyncServerDynamic fallback is clean dead code today but well-guarded and well-documented.

  2. 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.

  3. 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";
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.

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>)({});
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.

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),
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.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Mar 28, 2026

Review posted successfully. Here's a summary:

All three fixes are correct and CI is fully green.

  1. dynamic.ts — Removing "use client" is necessary so the RSC plugin doesn't replace the module with a client reference proxy. The switch to React.* namespace access avoids silent undefined values from destructured imports. The AsyncServerDynamic fallback is well-documented dead code today (React 19.x exports React.lazy from react-server), with good unit test coverage via React.lazy = undefined stubbing.

  2. app-rsc-entry.ts — One-line fix wrapping route handler params with makeThenableParams(), consistent with every other params context in the same file.

  3. layout-segment-context.tsx — Comment-only change, "use client" correctly retained.

One outstanding issue: The PR description still claims "use client" was removed from layout-segment-context.tsx, which has been flagged in all four prior reviews. The code is ready to merge once the description is corrected.

github run

@james-elicx james-elicx merged commit 7d0203d into cloudflare:main Mar 28, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants