Skip to content

Commit 3e00319

Browse files
[Flight] allow context providers from client modules (#35675)
Allows Server Components to import Context from a `"use client'` module and render its Provider. Only tricky part was that I needed to add `REACT_CONTEXT_TYPE` handling in mountLazyComponent so lazy-resolved Context types can be rendered. Previously only functions, REACT_FORWARD_REF_TYPE, and REACT_MEMO_TYPE were handled. Tested in the Flight fixture. ty bb claude Closes #35340 --------- Co-authored-by: Sophie Alpert <git@sophiebits.com>
1 parent 3419420 commit 3e00319

9 files changed

Lines changed: 145 additions & 40 deletions

File tree

fixtures/flight/src/App.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import {renderToReadableStream} from 'react-server-dom-unbundled/server';
33
import {createFromReadableStream} from 'react-server-dom-webpack/client';
44
import {PassThrough, Readable} from 'stream';
5-
5+
import {ClientContext, ClientReadContext} from './ClientContext.js';
66
import Container from './Container.js';
77

88
import {Counter} from './Counter.js';
@@ -235,6 +235,11 @@ export default async function App({prerender, noCache}) {
235235
<Foo>{dedupedChild}</Foo>
236236
<Bar>{Promise.resolve([dedupedChild])}</Bar>
237237
<Navigate />
238+
<ClientContext value="from server">
239+
<div>
240+
<ClientReadContext />
241+
</div>
242+
</ClientContext>
238243
{prerender ? null : ( // TODO: prerender is broken for large content for some reason.
239244
<React.Suspense fallback={null}>
240245
<LargeContent />
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client';
2+
3+
import {createContext, use} from 'react';
4+
5+
const ClientContext = createContext(null);
6+
7+
function ClientReadContext() {
8+
const value = use(ClientContext);
9+
return <p>{value}</p>;
10+
}
11+
12+
export {ClientContext, ClientReadContext};

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import {
128128
REACT_LAZY_TYPE,
129129
REACT_FORWARD_REF_TYPE,
130130
REACT_MEMO_TYPE,
131+
REACT_CONTEXT_TYPE,
131132
} from 'shared/ReactSymbols';
132133
import {setCurrentFiber} from './ReactCurrentFiber';
133134
import {
@@ -2140,6 +2141,10 @@ function mountLazyComponent(
21402141
props,
21412142
renderLanes,
21422143
);
2144+
} else if ($$typeof === REACT_CONTEXT_TYPE) {
2145+
workInProgress.tag = ContextProvider;
2146+
workInProgress.type = Component;
2147+
return updateContextProvider(null, workInProgress, renderLanes);
21432148
}
21442149
}
21452150

packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,44 @@ describe('ReactLazy', () => {
116116
expect(root).toMatchRenderedOutput('Hi again');
117117
});
118118

119+
it('renders a lazy context provider', async () => {
120+
const Context = React.createContext('default');
121+
function ConsumerText() {
122+
return <Text text={React.useContext(Context)} />;
123+
}
124+
// Context.Provider === Context, so we can lazy-load the context itself
125+
const LazyProvider = lazy(() => fakeImport(Context));
126+
127+
const root = ReactTestRenderer.create(
128+
<Suspense fallback={<Text text="Loading..." />}>
129+
<LazyProvider value="Hi">
130+
<ConsumerText />
131+
</LazyProvider>
132+
</Suspense>,
133+
{
134+
unstable_isConcurrent: true,
135+
},
136+
);
137+
138+
await waitForAll(['Loading...']);
139+
expect(root).not.toMatchRenderedOutput('Hi');
140+
141+
await act(() => resolveFakeImport(Context));
142+
assertLog(['Hi']);
143+
expect(root).toMatchRenderedOutput('Hi');
144+
145+
// Should not suspend on update
146+
root.update(
147+
<Suspense fallback={<Text text="Loading..." />}>
148+
<LazyProvider value="Hi again">
149+
<ConsumerText />
150+
</LazyProvider>
151+
</Suspense>,
152+
);
153+
await waitForAll(['Hi again']);
154+
expect(root).toMatchRenderedOutput('Hi again');
155+
});
156+
119157
it('can resolve synchronously without suspending', async () => {
120158
const LazyText = lazy(() => ({
121159
then(cb) {
@@ -858,13 +896,20 @@ describe('ReactLazy', () => {
858896
);
859897
});
860898

861-
it('throws with a useful error when wrapping Context with lazy()', async () => {
862-
const Context = React.createContext(null);
863-
const BadLazy = lazy(() => fakeImport(Context));
899+
it('renders a lazy context provider without value prop', async () => {
900+
// Context providers work when wrapped in lazy()
901+
const Context = React.createContext('default');
902+
const LazyProvider = lazy(() => fakeImport(Context));
903+
904+
function ConsumerText() {
905+
return <Text text={React.useContext(Context)} />;
906+
}
864907

865908
const root = ReactTestRenderer.create(
866909
<Suspense fallback={<Text text="Loading..." />}>
867-
<BadLazy />
910+
<LazyProvider value="provided">
911+
<ConsumerText />
912+
</LazyProvider>
868913
</Suspense>,
869914
{
870915
unstable_isConcurrent: true,
@@ -873,16 +918,9 @@ describe('ReactLazy', () => {
873918

874919
await waitForAll(['Loading...']);
875920

876-
await resolveFakeImport(Context);
877-
root.update(
878-
<Suspense fallback={<Text text="Loading..." />}>
879-
<BadLazy />
880-
</Suspense>,
881-
);
882-
await waitForThrow(
883-
'Element type is invalid. Received a promise that resolves to: Context. ' +
884-
'Lazy element type must resolve to a class or function.',
885-
);
921+
await act(() => resolveFakeImport(Context));
922+
assertLog(['provided']);
923+
expect(root).toMatchRenderedOutput('provided');
886924
});
887925

888926
it('throws with a useful error when wrapping Context.Consumer with lazy()', async () => {

packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,10 @@ const deepProxyHandlers: Proxy$traps<mixed> = {
182182
// $FlowFixMe[prop-missing]
183183
return Object.prototype[Symbol.toStringTag];
184184
case 'Provider':
185-
throw new Error(
186-
`Cannot render a Client Context Provider on the Server. ` +
187-
`Instead, you can export a Client Component wrapper ` +
188-
`that itself renders a Client Context Provider.`,
189-
);
185+
// Context.Provider === Context in React, so return the same reference.
186+
// This allows server components to render <ClientContext.Provider>
187+
// which will be serialized and executed on the client.
188+
return receiver;
190189
case 'then':
191190
throw new Error(
192191
`Cannot await or return from a thenable. ` +

packages/react-server-dom-unbundled/src/ReactFlightUnbundledReferences.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,10 @@ const deepProxyHandlers: Proxy$traps<mixed> = {
182182
// $FlowFixMe[prop-missing]
183183
return Object.prototype[Symbol.toStringTag];
184184
case 'Provider':
185-
throw new Error(
186-
`Cannot render a Client Context Provider on the Server. ` +
187-
`Instead, you can export a Client Component wrapper ` +
188-
`that itself renders a Client Context Provider.`,
189-
);
185+
// Context.Provider === Context in React, so return the same reference.
186+
// This allows server components to render <ClientContext.Provider>
187+
// which will be serialized and executed on the client.
188+
return receiver;
190189
case 'then':
191190
throw new Error(
192191
`Cannot await or return from a thenable. ` +

packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,10 @@ const deepProxyHandlers: Proxy$traps<mixed> = {
182182
// $FlowFixMe[prop-missing]
183183
return Object.prototype[Symbol.toStringTag];
184184
case 'Provider':
185-
throw new Error(
186-
`Cannot render a Client Context Provider on the Server. ` +
187-
`Instead, you can export a Client Component wrapper ` +
188-
`that itself renders a Client Context Provider.`,
189-
);
185+
// Context.Provider === Context in React, so return the same reference.
186+
// This allows server components to render <ClientContext.Provider>
187+
// which will be serialized and executed on the client.
188+
return receiver;
190189
case 'then':
191190
throw new Error(
192191
`Cannot await or return from a thenable. ` +

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -787,19 +787,68 @@ describe('ReactFlightDOM', () => {
787787
<ClientModule.Component key="this adds instrumentation" />;
788788
});
789789

790-
it('throws when accessing a Context.Provider below the client exports', () => {
790+
it('does not throw when accessing a Context.Provider from client exports', () => {
791791
const Context = React.createContext();
792792
const ClientModule = clientExports({
793793
Context,
794794
});
795795
function dotting() {
796796
return ClientModule.Context.Provider;
797797
}
798-
expect(dotting).toThrowError(
799-
`Cannot render a Client Context Provider on the Server. ` +
800-
`Instead, you can export a Client Component wrapper ` +
801-
`that itself renders a Client Context Provider.`,
798+
expect(dotting).not.toThrowError();
799+
});
800+
801+
it('can render a client Context.Provider from a server component', async () => {
802+
// Create a context in a client module
803+
const TestContext = React.createContext('default');
804+
const ClientModule = clientExports({
805+
TestContext,
806+
});
807+
808+
// Client component that reads context
809+
function ClientConsumer() {
810+
const value = React.useContext(TestContext);
811+
return <span>{value}</span>;
812+
}
813+
const {ClientConsumer: ClientConsumerRef} = clientExports({ClientConsumer});
814+
815+
function Print({response}) {
816+
return use(response);
817+
}
818+
819+
function App({response}) {
820+
return (
821+
<Suspense fallback={<h1>Loading...</h1>}>
822+
<Print response={response} />
823+
</Suspense>
824+
);
825+
}
826+
827+
// Server component that provides context
828+
function ServerApp() {
829+
return (
830+
<ClientModule.TestContext.Provider value="from-server">
831+
<div>
832+
<ClientConsumerRef />
833+
</div>
834+
</ClientModule.TestContext.Provider>
835+
);
836+
}
837+
838+
const {writable, readable} = getTestStream();
839+
const {pipe} = await serverAct(() =>
840+
ReactServerDOMServer.renderToPipeableStream(<ServerApp />, webpackMap),
802841
);
842+
pipe(writable);
843+
const response = ReactServerDOMClient.createFromReadableStream(readable);
844+
845+
const container = document.createElement('div');
846+
const root = ReactDOMClient.createRoot(container);
847+
await act(() => {
848+
root.render(<App response={response} />);
849+
});
850+
851+
expect(container.innerHTML).toBe('<div><span>from-server</span></div>');
803852
});
804853

805854
it('should progressively reveal server components', async () => {

packages/react-server/src/ReactFlightServerTemporaryReferences.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,10 @@ const proxyHandlers: Proxy$traps<mixed> = {
6565
// $FlowFixMe[prop-missing]
6666
return Object.prototype[Symbol.toStringTag];
6767
case 'Provider':
68-
throw new Error(
69-
`Cannot render a Client Context Provider on the Server. ` +
70-
`Instead, you can export a Client Component wrapper ` +
71-
`that itself renders a Client Context Provider.`,
72-
);
68+
// Context.Provider === Context in React, so return the same reference.
69+
// This allows server components to render <ClientContext.Provider>
70+
// which will be serialized and executed on the client.
71+
return receiver;
7372
case 'then':
7473
// Allow returning a temporary reference from an async function
7574
// Unlike regular Client References, a Promise would never have been serialized as

0 commit comments

Comments
 (0)