fix(shims): handle memo/forwardRef components in next/dynamic module resolution#852
Conversation
commit: |
08a6596 to
ad21e61
Compare
React.lazy() requires `{ default: Function }`, but React.memo() and
React.forwardRef() produce objects with a `$$typeof` Symbol instead of
plain functions. When a library exports a memo-wrapped or forwardRef-wrapped
component as its default export, the current `"default" in mod` check
passes but React.lazy fails at render time with:
"Element type is invalid: expected a string or class/function but got: object"
This adds a 4-tier `resolveModule()` function that handles all export shapes:
1. `mod.default` is a function → use directly (most common, no change)
2. `mod.default` is a `$$typeof` object (memo/forwardRef) → wrap in function component
3. No usable default → try first named function export
4. First named `$$typeof` export → wrap
The wrapper creates a thin function component via `React.createElement(comp, props)`
so React.lazy can consume it. Display names are preserved for DevTools.
Applied to all 4 module resolution sites: client SSR-false, async server,
server lazy, and client lazy paths.
ad21e61 to
33715d7
Compare
|
/bigbonk review |
There was a problem hiding this comment.
Summary
This PR adds resolveModule() with $$typeof detection and wrapNonFunctionComponent() to handle React.memo() / React.forwardRef() objects in next/dynamic. However, after investigation, this change is unnecessary and introduces regressions.
The core problem doesn't exist in React 19
The PR description states:
React.lazy()requires{ default: Function }, butReact.memo()andReact.forwardRef()produce objects with a$$typeofSymbol instead of plain functions.
This was true in older React versions, but not in React 19 (which vinext uses). I verified this directly:
const Memo = React.memo(() => React.createElement('div', null, 'hello'));
const Lazy = React.lazy(() => Promise.resolve({ default: Memo }));
// renderToReadableStream with Suspense → renders "hello" successfullyBoth React.memo() and React.forwardRef() objects work perfectly as { default: ... } values with React.lazy() in React 19. The original "default" in mod check passes, React.lazy gets { default: <memo object> }, and rendering works.
Next.js doesn't do this either
Next.js's own next/dynamic implementation (packages/next/src/shared/lib/lazy-dynamic/loadable.tsx) uses a simple convertModule that does no $$typeof detection or wrapping:
function convertModule<P>(mod) {
return { default: ('default' in mod) ? mod.default : mod };
}It relies on React's built-in ability to render memo/forwardRef objects.
The wrapper actively breaks forwardRef
The wrapNonFunctionComponent creates a plain function component:
const Wrapper = (props: P) => React.createElement(c, props);This destroys ref forwarding. A React.forwardRef component that previously accepted refs will silently stop forwarding them when wrapped this way, because plain function components don't receive a ref parameter. This is a regression for any library exporting forwardRef-wrapped components as default exports.
Cases 3 & 4 are nondeterministic
The named-export fallback (Cases 3 & 4) picks Object.values(mod)[0] — whichever function or $$typeof object happens to be first in insertion order. If a module exports { Sidebar, Header, Footer } with no usable default, the component rendered depends on export ordering. This is fragile and would produce confusing bugs.
No tests
No tests were added or modified. The test plan in the PR description lists checkboxes for manual testing, but the existing tests/dynamic.test.ts and tests/shims.test.ts have zero coverage for this new logic.
Recommendation
I'd recommend closing this PR. If there's a specific library where next/dynamic fails with a memo/forwardRef default export, that would be worth investigating as a separate bug report — but it's likely caused by something else (e.g., CJS interop double-wrapping mod.default.default), not by React.lazy's inability to handle these types.
| // 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; |
There was a problem hiding this comment.
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 forwardRefAfter 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 renderToReadableStreamNext.js itself doesn't do any wrapping — its convertModule just passes the value through.
| const namedFns = Object.values(mod).filter( | ||
| (v): v is ComponentType<P> => typeof v === "function", | ||
| ); | ||
| if (namedFns.length > 0) { |
There was a problem hiding this comment.
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.
| * 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> } { |
There was a problem hiding this comment.
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 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) { | ||
| 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> }; | ||
| } |
There was a problem hiding this comment.
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.
|
Review submitted. Here's a summary of my findings: This PR should not be merged. The core premise — that
|
|
Closing this PR based on review feedback. The premise was incorrect: React 19's The $$typeof handling in our source project was a workaround for a different environment (earlier React / specific Vite pre-bundling interactions) and I incorrectly assumed vinext needed the same fix without verifying against React 19. Thanks for the detailed review — apologies for the noise. |
Problem
React.lazy()requires{ default: Function }, butReact.memo()andReact.forwardRef()produce objects with a$$typeofSymbol instead of plain functions.When a library exports a memo-wrapped or forwardRef-wrapped component as its default export:
The current
"default" in modcheck indynamic.tspasses, but React.lazy fails at render time:This affects all 4 lazy paths: client SSR-false, async server, server lazy, and client lazy.
Solution
Added a
resolveModule()function with 4-tier fallback:mod.defaultis a function → use directly (most common, no behavior change)mod.defaultis a$$typeofobject (memo/forwardRef) → wrap in thin function component$$typeofexport → wrapThe wrapper creates a thin function component via
React.createElement(comp, props)soReact.lazy()can consume it. Display names are preserved for DevTools.Changes
wrapNonFunctionComponent()helperresolveModule()with 4-tier resolution"default" in modchecks withresolveModule()callTest plan
🤖 Generated with Claude Code