Skip to content

Commit 5c36388

Browse files
pass props to RSC generator and avoid state reset on hydration
1 parent dcc32be commit 5c36388

File tree

4 files changed

+77
-51
lines changed

4 files changed

+77
-51
lines changed

jest.config.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ module.exports = {
66
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
77
// React Server Components tests are not compatible with Experimental React 18 and React 19
88
// That only run with node version 18 and above
9-
moduleNameMapper: nodeVersion < 18
10-
? {
11-
'react-server-dom-webpack/client': '<rootDir>/node_package/tests/emptyForTesting.js',
12-
'^@testing-library/dom$': '<rootDir>/node_package/tests/emptyForTesting.js',
13-
'^@testing-library/react$': '<rootDir>/node_package/tests/emptyForTesting.js',
14-
}
15-
: {},
9+
moduleNameMapper:
10+
nodeVersion < 18
11+
? {
12+
'react-server-dom-webpack/client': '<rootDir>/node_package/tests/emptyForTesting.js',
13+
'^@testing-library/dom$': '<rootDir>/node_package/tests/emptyForTesting.js',
14+
'^@testing-library/react$': '<rootDir>/node_package/tests/emptyForTesting.js',
15+
}
16+
: {},
1617
};

node_package/src/RSCClientRoot.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
11
"use client";
22

33
import * as React from 'react';
4+
import ReactDOMClient from 'react-dom/client';
45
import RSDWClient from 'react-server-dom-webpack/client';
56
import { fetch } from './utils';
67
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';
8+
import { RailsContext, RenderFunction } from './types';
79

810
const { use } = React;
911

1012
if (typeof use !== 'function') {
1113
throw new Error('React.use is not defined. Please ensure you are using React 18 with experimental features enabled or React 19+ to use server components.');
1214
}
1315

14-
let renderCache: Record<string, Promise<React.ReactNode>> = {};
15-
export const resetRenderCache = () => {
16-
renderCache = {};
17-
}
18-
1916
export type RSCClientRootProps = {
2017
componentName: string;
2118
rscPayloadGenerationUrlPath: string;
19+
componentProps?: unknown;
2220
}
2321

2422
const createFromFetch = async (fetchPromise: Promise<Response>) => {
@@ -31,12 +29,10 @@ const createFromFetch = async (fetchPromise: Promise<Response>) => {
3129
return RSDWClient.createFromReadableStream(transformedStream);
3230
}
3331

34-
const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath }: RSCClientRootProps) => {
35-
if (!renderCache[componentName]) {
36-
const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, '');
37-
renderCache[componentName] = createFromFetch(fetch(`/${strippedUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
38-
}
39-
return renderCache[componentName];
32+
const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath, componentProps }: RSCClientRootProps) => {
33+
const propsString = JSON.stringify(componentProps);
34+
const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, '');
35+
return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`)) as Promise<React.ReactNode>;
4036
}
4137

4238
/**
@@ -52,9 +48,28 @@ const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath }: RSCClientRootP
5248
* @requires React 18+ with experimental features or React 19+
5349
* @requires react-server-dom-webpack/client
5450
*/
55-
const RSCClientRoot = ({
51+
const RSCClientRoot: RenderFunction = async ({
5652
componentName,
5753
rscPayloadGenerationUrlPath,
58-
}: RSCClientRootProps) => use(fetchRSC({ componentName, rscPayloadGenerationUrlPath }));
54+
componentProps,
55+
}: RSCClientRootProps, _railsContext?: RailsContext, domNodeId?: string) => {
56+
const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps })
57+
if (!domNodeId) {
58+
throw new Error('RSCClientRoot: No domNodeId provided');
59+
}
60+
const domNode = document.getElementById(domNodeId);
61+
if (!domNode) {
62+
throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`);
63+
}
64+
if (domNode.innerHTML) {
65+
ReactDOMClient.hydrateRoot(domNode, root);
66+
} else {
67+
ReactDOMClient.createRoot(domNode).render(root);
68+
}
69+
// Added only to satisfy the return type of RenderFunction
70+
// However, the returned value of renderFunction is not used in ReactOnRails
71+
// TODO: fix this behavior
72+
return '';
73+
}
5974

6075
export default RSCClientRoot;

node_package/src/registerServerComponent.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import React from 'react';
21
import ReactOnRails from './ReactOnRails';
32
import RSCClientRoot from './RSCClientRoot';
4-
import { RegisterServerComponentOptions } from './types';
3+
import { RegisterServerComponentOptions, RailsContext, ReactComponentOrRenderFunction } from './types';
54

65
/**
76
* Registers React Server Components (RSC) with React on Rails.
@@ -36,12 +35,13 @@ import { RegisterServerComponentOptions } from './types';
3635
* ```
3736
*/
3837
const registerServerComponent = (options: RegisterServerComponentOptions, ...componentNames: string[]) => {
39-
const componentsWrappedInRSCClientRoot: Record<string, () => React.ReactElement> = {};
38+
const componentsWrappedInRSCClientRoot: Record<string, ReactComponentOrRenderFunction> = {};
4039
for (const name of componentNames) {
41-
componentsWrappedInRSCClientRoot[name] = () => React.createElement(RSCClientRoot, {
40+
componentsWrappedInRSCClientRoot[name] = (componentProps?: unknown, _railsContext?: RailsContext, domNodeId?: string) => RSCClientRoot({
4241
componentName: name,
43-
rscPayloadGenerationUrlPath: options.rscPayloadGenerationUrlPath
44-
});
42+
rscPayloadGenerationUrlPath: options.rscPayloadGenerationUrlPath,
43+
componentProps,
44+
}, _railsContext, domNodeId);
4545
}
4646
ReactOnRails.register(componentsWrappedInRSCClientRoot);
4747
};

node_package/tests/RSCClientRoot.test.jsx

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,35 @@
88
window.__webpack_require__ = jest.fn();
99
window.__webpack_chunk_load__ = jest.fn();
1010

11-
import * as React from 'react';
1211
import { enableFetchMocks } from 'jest-fetch-mock';
13-
import { render, screen, act } from '@testing-library/react';
12+
import { screen, act } from '@testing-library/react';
1413
import '@testing-library/jest-dom';
1514
import path from 'path';
1615
import fs from 'fs';
1716
import { createNodeReadableStream, getNodeVersion } from './testUtils';
1817

19-
import RSCClientRoot, { resetRenderCache } from '../src/RSCClientRoot';
18+
import RSCClientRoot from '../src/RSCClientRoot';
2019

2120
enableFetchMocks();
2221

2322
// React Server Components tests are not compatible with Experimental React 18 and React 19
2423
// That only run with node version 18 and above
2524
(getNodeVersion() >= 18 ? describe : describe.skip)('RSCClientRoot', () => {
25+
let container;
26+
const mockDomNodeId = 'test-container';
27+
2628
beforeEach(() => {
29+
// Setup DOM element
30+
container = document.createElement('div');
31+
container.id = mockDomNodeId;
32+
document.body.appendChild(container);
2733
jest.clearAllMocks();
2834

2935
jest.resetModules();
30-
resetRenderCache();
36+
});
37+
38+
afterEach(() => {
39+
document.body.removeChild(container);
3140
});
3241

3342
it('throws error when React.use is not defined', () => {
@@ -60,27 +69,32 @@ enableFetchMocks();
6069
rscPayloadGenerationUrlPath,
6170
};
6271

63-
const { rerender } = await act(async () => render(<RSCClientRoot {...props} />));
72+
// Execute the render
73+
const render = () =>
74+
act(async () => {
75+
await RSCClientRoot(props, undefined, mockDomNodeId);
76+
});
6477

6578
return {
66-
rerender: () => rerender(<RSCClientRoot {...props} />),
79+
render,
6780
pushFirstChunk: () => push(`${JSON.stringify(chunk1)}\n`),
6881
pushSecondChunk: () => push(`${JSON.stringify(chunk2)}\n`),
6982
pushCustomChunk: (chunk) => push(`${chunk}\n`),
7083
endStream: () => push(null),
7184
};
7285
};
7386

74-
it('fetches and caches component data', async () => {
75-
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest();
87+
it('renders component progressively', async () => {
88+
const { render, pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest();
7689

77-
expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent');
78-
expect(window.fetch).toHaveBeenCalledTimes(1);
7990
expect(screen.queryByText('StaticServerComponent')).not.toBeInTheDocument();
8091

8192
await act(async () => {
8293
pushFirstChunk();
94+
render();
8395
});
96+
expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent?props=undefined');
97+
expect(window.fetch).toHaveBeenCalledTimes(1);
8498
expect(screen.getByText('StaticServerComponent')).toBeInTheDocument();
8599
expect(screen.getByText('Loading AsyncComponent...')).toBeInTheDocument();
86100
expect(screen.queryByText('AsyncComponent')).not.toBeInTheDocument();
@@ -91,24 +105,21 @@ enableFetchMocks();
91105
});
92106
expect(screen.getByText('AsyncComponent')).toBeInTheDocument();
93107
expect(screen.queryByText('Loading AsyncComponent...')).not.toBeInTheDocument();
94-
95-
// Second render - should use cache
96-
rerender();
97-
98-
expect(screen.getByText('AsyncComponent')).toBeInTheDocument();
99-
expect(window.fetch).toHaveBeenCalledTimes(1);
100108
});
101109

102110
it('replays console logs', async () => {
103111
const consoleSpy = jest.spyOn(console, 'log');
104-
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest();
112+
const { render, pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest();
105113

106114
await act(async () => {
115+
render();
107116
pushFirstChunk();
108117
});
109118
expect(consoleSpy).toHaveBeenCalledWith(
110119
expect.stringContaining('Console log at first chunk'),
111-
expect.anything(), expect.anything(), expect.anything()
120+
expect.anything(),
121+
expect.anything(),
122+
expect.anything(),
112123
);
113124
expect(consoleSpy).toHaveBeenCalledTimes(1);
114125

@@ -117,28 +128,27 @@ enableFetchMocks();
117128
});
118129
expect(consoleSpy).toHaveBeenCalledWith(
119130
expect.stringContaining('Console log at second chunk'),
120-
expect.anything(), expect.anything(), expect.anything()
131+
expect.anything(),
132+
expect.anything(),
133+
expect.anything(),
121134
);
122135
await act(async () => {
123136
endStream();
124137
});
125138
expect(consoleSpy).toHaveBeenCalledTimes(2);
126-
127-
// On rerender, console logs should not be replayed again
128-
rerender();
129-
expect(consoleSpy).toHaveBeenCalledTimes(2);
130139
});
131140

132141
it('strips leading and trailing slashes from rscPayloadGenerationUrlPath', async () => {
133-
const { pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest('/rsc-render/');
142+
const { render, pushFirstChunk, pushSecondChunk, endStream } = await mockRSCRequest('/rsc-render/');
134143

135144
await act(async () => {
145+
render();
136146
pushFirstChunk();
137147
pushSecondChunk();
138148
endStream();
139149
});
140150

141-
expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent');
151+
expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent?props=undefined');
142152
expect(window.fetch).toHaveBeenCalledTimes(1);
143153

144154
expect(screen.getByText('StaticServerComponent')).toBeInTheDocument();

0 commit comments

Comments
 (0)