Skip to content
Closed
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
85 changes: 74 additions & 11 deletions packages/vinext/src/shims/dynamic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,75 @@ function getDynamicErrorBoundary() {
// Detect server vs client
const isServer = typeof window === "undefined";

/**
* Wraps non-function components (React.memo / React.forwardRef objects) into
* a plain function component so React.lazy() can consume them.
*
* React.lazy() requires `{ default: Function }`, but memo/forwardRef produce
* objects with a `$$typeof` Symbol. Without this wrapper, lazy() throws:
* "React.lazy() only accepts functions returning a module with a default export"
*/
function wrapNonFunctionComponent<P>(comp: unknown): ComponentType<P> {
// oxlint-disable-next-line typescript/no-explicit-any
const c = comp as any;
// oxlint-disable-next-line typescript/no-explicit-any
const Wrapper = (props: P) => React.createElement(c, props as any);
Wrapper.displayName = `DynamicWrapper(${c.displayName || c.name || "Unknown"})`;
return Wrapper;
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 wrapper breaks React.forwardRef components. It creates a plain function component that doesn't accept or forward a ref parameter, so any forwardRef-wrapped component passed through here silently loses ref support.

For example:

const Input = React.forwardRef((props, ref) => <input ref={ref} {...props} />);
export default React.memo(Input); // memo wrapping forwardRef

After wrapping, React.createElement(Wrapper, props) never passes the ref through.

But more fundamentally — this wrapper isn't needed. React 19's React.lazy() natively handles { default: <memo/forwardRef object> }. I verified this:

const Memo = React.memo(MyComponent);
const Lazy = React.lazy(() => Promise.resolve({ default: Memo }));
// renders correctly with renderToReadableStream

Next.js itself doesn't do any wrapping — its convertModule just passes the value through.

}

/**
* Resolve a dynamically imported module to `{ default: ComponentType }`.
*
* Handles 4 cases in priority order:
* 1. `mod.default` is a function → use directly
* 2. `mod.default` is a React element type (memo/forwardRef with $$typeof) → wrap
* 3. First named export that is a function → use as default
* 4. First named export with $$typeof → wrap
*
* This covers edge cases where libraries export memo-wrapped or forwardRef-wrapped
* components as default, which React.lazy cannot consume directly.
*/
function resolveModule<P>(mod: Record<string, unknown>): { default: 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 mod parameter is typed as Record<string, unknown> but accessed with mod?.default (optional chaining) — if mod could be nullish, the type should reflect that (Record<string, unknown> | null | undefined). If it can't be nullish (the Loader type guarantees a resolved value), the ?. is unnecessary noise.

Also, at line 142, "default" in mod will throw if mod is actually null/undefined, contradicting the optional chaining above.

// Case 1: default export is a function (most common)
if (mod?.default && typeof mod.default === "function") {
return { default: mod.default as ComponentType<P> };
}

// Case 2: default export is a React element type object (memo/forwardRef)
// oxlint-disable-next-line typescript/no-explicit-any
if (
mod?.default &&
typeof mod.default === "object" &&
(mod.default as Record<string, unknown>)?.$$typeof
) {
return { default: wrapNonFunctionComponent<P>(mod.default) };
}

// Case 3: no usable default — try first named function export
if (mod) {
const namedFns = Object.values(mod).filter(
(v): v is ComponentType<P> => typeof v === "function",
);
if (namedFns.length > 0) {
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 picks the first function from Object.values(mod), which is insertion-order dependent. If a module exports multiple functions (e.g., { Sidebar, Header, Footer } with a falsy default), this silently picks whichever one was defined first. That's nondeterministic from the user's perspective and would produce confusing rendering bugs.

The original code's behavior for this case (falling through to the "default" in mod / cast-as-ComponentType fallback) was also imperfect, but at least it was predictable — it wouldn't silently render a random named export.

return { default: namedFns[0] };
}

// Case 4: first named $$typeof export
const namedObjects = Object.values(mod).filter(
(v): v is unknown =>
v != null && typeof v === "object" && !!(v as Record<string, unknown>)?.$$typeof,
);
if (namedObjects.length > 0) {
return { default: wrapNonFunctionComponent<P>(namedObjects[0]) };
}
}

// Fallback: preserve original behavior
if ("default" in mod) return mod as { default: ComponentType<P> };
return { default: mod as unknown as ComponentType<P> };
}
Comment on lines +122 to +144
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.

Cases 3 and 4 iterate all module exports looking for functions or $$typeof objects. This changes behavior for the existing "bare component" case (where the loader returns the component directly, not wrapped in { default: ... }). The original code handled this with a simple cast at the end. Now it might accidentally match utility functions, constants with $$typeof, or other non-component exports before reaching the fallback.

The Loader<P> type already defines the return as Promise<{ default: ComponentType<P> } | ComponentType<P>> — there are only two valid shapes. Handling anything else is speculative and risks silent misbehavior.


// Legacy preload queue — kept for backward compatibility with Pages Router
// which calls flushPreloads() before rendering. The App Router uses React.lazy
// + Suspense instead, so this queue is no longer populated.
Expand Down Expand Up @@ -110,8 +179,7 @@ function dynamic<P extends object = object>(
// Client: use lazy with Suspense
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> };
return resolveModule<P>(mod as Record<string, unknown>);
});

const ClientSSRFalse = (props: P) => {
Expand Down Expand Up @@ -151,11 +219,8 @@ function dynamic<P extends object = object>(
// provide loading states. Error handling also defers to the nearest
// error boundary in the component tree.
const mod = await loader();
const Component =
"default" in mod
? (mod as { default: ComponentType<P> }).default
: (mod as ComponentType<P>);
return React.createElement(Component, props);
const resolved = resolveModule<P>(mod as Record<string, unknown>);
return React.createElement(resolved.default, props);
};
AsyncServerDynamic.displayName = "DynamicAsyncServer";
// Cast is safe: async components are natively supported by the RSC renderer,
Expand All @@ -167,8 +232,7 @@ function dynamic<P extends object = object>(
// 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> };
return resolveModule<P>(mod as Record<string, unknown>);
});

const ServerDynamic = (props: P) => {
Expand All @@ -192,8 +256,7 @@ function dynamic<P extends object = object>(
// Client path: standard React.lazy with Suspense
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> };
return resolveModule<P>(mod as Record<string, unknown>);
});

const ClientDynamic = (props: P) => {
Expand Down
Loading