Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1957,7 +1957,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) {
if (typeof handlerFn === "function") {
const previousHeadersPhase = setHeadersAccessPhase("route-handler");
try {
const response = await handlerFn(request, { params });
const response = await handlerFn(request, { params: makeThenableParams(params) });
const dynamicUsedInHandler = consumeDynamicUsage();

// Apply Cache-Control from route segment config (export const revalidate = N).
Expand Down
49 changes: 33 additions & 16 deletions packages/vinext/src/shims/dynamic.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"use client";
/**
* next/dynamic shim
*
* SSR-safe dynamic imports. On the server, uses React.lazy + Suspense so that
* renderToReadableStream suspends until the dynamically-imported component is
* 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
* - SSR: React.lazy + Suspense (renderToReadableStream suspends)
* - Client: React.lazy + Suspense (standard code splitting)
*
* Supports:
* - dynamic(() => import('./Component'))
* - dynamic(() => import('./Component'), { loading: () => <Spinner /> })
* - dynamic(() => import('./Component'), { ssr: false })
*/
import React, { lazy, Suspense, type ComponentType, useState, useEffect } from "react";
import React, { type ComponentType } from "react";

interface DynamicOptions {
loading?: ComponentType<{ error?: Error | null; isLoading?: boolean; pastDelay?: boolean }>;
Expand Down Expand Up @@ -90,7 +94,7 @@ function dynamic<P extends object = object>(
// ssr: false — render nothing on the server, lazy-load on client
if (!ssr) {
if (isServer) {
// On the server, just render the loading state or nothing
// 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 })
Expand All @@ -101,15 +105,15 @@ function dynamic<P extends object = object>(
}

// Client: use lazy with Suspense
const LazyComponent = lazy(async () => {
const LazyComponent = React.lazy(async () => {
const mod = await loader();
if ("default" in mod) return mod as { default: ComponentType<P> };
return { default: mod as ComponentType<P> };
});

const ClientSSRFalse = (props: P) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);

if (!mounted) {
return LoadingComponent
Expand All @@ -120,7 +124,7 @@ function dynamic<P extends object = object>(
const fallback = LoadingComponent
? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null })
: null;
return React.createElement(Suspense, { fallback }, React.createElement(LazyComponent, props));
return React.createElement(React.Suspense, { fallback }, React.createElement(LazyComponent, props));
};

ClientSSRFalse.displayName = "DynamicClientSSRFalse";
Expand All @@ -129,12 +133,25 @@ function dynamic<P extends object = object>(

// SSR-enabled path
if (isServer) {
// Use React.lazy so that renderToReadableStream can suspend until the
// dynamically-imported component is available. The previous eager-load
// pattern relied on flushPreloads() being called before rendering, which
// works for the Pages Router but not the App Router where client modules
// are loaded lazily during RSC stream deserialization (issue #75).
const LazyServer = lazy(async () => {
// In RSC environment, React.lazy is not available (react-server condition
// 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") {
const AsyncServerDynamic = async (props: P) => {
Copy link
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.

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

const mod = await loader();
const Component = "default" in mod
? (mod as { default: ComponentType<P> }).default
: mod as ComponentType<P>;
return React.createElement(Component, props);
};
AsyncServerDynamic.displayName = "DynamicAsyncServer";
return AsyncServerDynamic as unknown as ComponentType<P>;
Copy link
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>;

}

// SSR path: Use React.lazy so that renderToReadableStream can suspend
// until the dynamically-imported component is available.
const LazyServer = React.lazy(async () => {
const mod = await loader();
if ("default" in mod) return mod as { default: ComponentType<P> };
return { default: mod as ComponentType<P> };
Expand All @@ -151,15 +168,15 @@ function dynamic<P extends object = object>(
const content = ErrorBoundary
? React.createElement(ErrorBoundary, { fallback: LoadingComponent }, lazyElement)
: lazyElement;
return React.createElement(Suspense, { fallback }, content);
return React.createElement(React.Suspense, { fallback }, content);
};

ServerDynamic.displayName = "DynamicServer";
return ServerDynamic;
}

// Client path: standard React.lazy with Suspense
const LazyComponent = lazy(async () => {
const LazyComponent = React.lazy(async () => {
const mod = await loader();
if ("default" in mod) return mod as { default: ComponentType<P> };
return { default: mod as ComponentType<P> };
Expand All @@ -169,7 +186,7 @@ function dynamic<P extends object = object>(
const fallback = LoadingComponent
? React.createElement(LoadingComponent, { isLoading: true, pastDelay: true, error: null })
: null;
return React.createElement(Suspense, { fallback }, React.createElement(LazyComponent, props));
return React.createElement(React.Suspense, { fallback }, React.createElement(LazyComponent, props));
};

ClientDynamic.displayName = "DynamicClient";
Expand Down
9 changes: 4 additions & 5 deletions packages/vinext/src/shims/layout-segment-context.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"use client";

/**
* Layout segment context provider.
*
* 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.
* Does NOT use "use client" — this module is imported directly by the RSC
* entry which runs in the react-server condition. getLayoutSegmentContext()
* returns null when React.createContext is unavailable (RSC), and the
* provider gracefully falls back to passing children through unchanged.
*
* The context is shared with navigation.ts via getLayoutSegmentContext()
* to avoid creating separate contexts in different modules.
Expand Down
Loading