Skip to content

Commit e1fdfc7

Browse files
add tests for RSCClientRoot
1 parent c7a2731 commit e1fdfc7

File tree

11 files changed

+524
-11
lines changed

11 files changed

+524
-11
lines changed

Gemfile.lock

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,6 @@ GEM
385385
nokogiri (~> 1.6)
386386
rubyzip (>= 1.3.0)
387387
selenium-webdriver (~> 4.0, < 4.11)
388-
webpacker (6.0.0.rc.6)
389-
activesupport (>= 5.2)
390-
rack-proxy (>= 0.6.1)
391-
railties (>= 5.2)
392-
semantic_range (>= 2.3.0)
393388
webrick (1.8.1)
394389
websocket (1.2.10)
395390
websocket-driver (0.7.6)
@@ -444,7 +439,6 @@ DEPENDENCIES
444439
turbolinks
445440
uglifier
446441
webdrivers (= 5.3.0)
447-
webpacker (= 6.0.0.rc.6)
448442

449443
BUNDLED WITH
450444
2.5.9

node_package/src/RSCClientRoot.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import * as React from 'react';
22
import RSDWClient from 'react-server-dom-webpack/client';
3+
import { fetch } from './utils';
34
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';
45

5-
if (!('use' in React)) {
6+
if (!('use' in React && typeof React.use === 'function')) {
67
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.');
78
}
89

910
const { use } = React;
1011

11-
const renderCache: Record<string, Promise<React.ReactNode>> = {};
12+
let renderCache: Record<string, Promise<React.ReactNode>> = {};
13+
export const resetRenderCache = () => {
14+
renderCache = {};
15+
}
1216

1317
export type RSCClientRootProps = {
1418
componentName: string;

node_package/src/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Override the fetch function to make it easier to test and for future use
2+
const customFetch = (...args: Parameters<typeof fetch>) => {
3+
const res = fetch(...args);
4+
return res;
5+
}
6+
7+
// eslint-disable-next-line import/prefer-default-export
8+
export { customFetch as fetch };
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/* eslint-disable no-underscore-dangle */
2+
/* eslint-disable import/first */
3+
/**
4+
* @jest-environment jsdom
5+
*/
6+
7+
// Mock webpack require system for RSC
8+
window.__webpack_require__ = jest.fn();
9+
window.__webpack_chunk_load__ = jest.fn();
10+
11+
import * as React from 'react';
12+
import { enableFetchMocks } from 'jest-fetch-mock';
13+
import { render, waitFor, screen } from '@testing-library/react';
14+
import '@testing-library/jest-dom';
15+
import path from 'path';
16+
import fs from 'fs';
17+
import { createNodeReadableStream } from './testUtils';
18+
19+
// const __filename = fileURLToPath(import.meta.url);
20+
// const __dirname = path.dirname(__filename);
21+
22+
import RSCClientRoot, {resetRenderCache } from '../src/RSCClientRoot';
23+
24+
enableFetchMocks();
25+
26+
describe('RSCClientRoot', () => {
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
30+
jest.resetModules();
31+
resetRenderCache();
32+
});
33+
34+
it('throws error when React.use is not defined', () => {
35+
jest.mock('react', () => ({
36+
...jest.requireActual('react'),
37+
use: undefined
38+
}));
39+
40+
expect(() => {
41+
// Re-import to trigger the check
42+
jest.requireActual('../src/RSCClientRoot');
43+
}).toThrow('React.use is not defined');
44+
});
45+
46+
const mockRSCRequest = (rscRenderingUrlPath = 'rsc-render') => {
47+
const chunksDirectory = path.join(__dirname, 'fixtures', 'rsc-payloads', 'simple-shell-with-async-component');
48+
const chunk1 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk1.json'), 'utf8'));
49+
const chunk2 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk2.json'), 'utf8'));
50+
51+
const { stream, push } = createNodeReadableStream();
52+
window.fetchMock.mockResolvedValue(new Response(stream));
53+
54+
const props = {
55+
componentName: 'TestComponent',
56+
rscRenderingUrlPath
57+
};
58+
59+
const { rerender } = render(<RSCClientRoot {...props} />);
60+
61+
return {
62+
rerender: () => rerender(<RSCClientRoot {...props} />),
63+
pushFirstChunk: () => push(JSON.stringify(chunk1)),
64+
pushSecondChunk: () => push(JSON.stringify(chunk2)),
65+
pushCustomChunk: (chunk) => push(chunk),
66+
endStream: () => push(null),
67+
}
68+
}
69+
70+
it('fetches and caches component data', async () => {
71+
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest();
72+
73+
expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent');
74+
expect(window.fetch).toHaveBeenCalledTimes(1);
75+
expect(screen.queryByText('StaticServerComponent')).not.toBeInTheDocument();
76+
77+
pushFirstChunk();
78+
await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument());
79+
expect(screen.getByText('Loading AsyncComponent...')).toBeInTheDocument();
80+
expect(screen.queryByText('AsyncComponent')).not.toBeInTheDocument();
81+
82+
pushSecondChunk();
83+
endStream();
84+
await waitFor(() => expect(screen.getByText('AsyncComponent')).toBeInTheDocument());
85+
expect(screen.queryByText('Loading AsyncComponent...')).not.toBeInTheDocument();
86+
87+
// Second render - should use cache
88+
rerender();
89+
90+
expect(screen.getByText('AsyncComponent')).toBeInTheDocument();
91+
expect(window.fetch).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it('replays console logs', async () => {
95+
const consoleSpy = jest.spyOn(console, 'log');
96+
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest();
97+
98+
pushFirstChunk();
99+
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at first chunk'));
100+
expect(consoleSpy).toHaveBeenCalledTimes(1);
101+
102+
pushSecondChunk();
103+
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at second chunk'));
104+
endStream();
105+
expect(consoleSpy).toHaveBeenCalledTimes(2);
106+
107+
// On rerender, console logs should not be replayed again
108+
rerender();
109+
expect(consoleSpy).toHaveBeenCalledTimes(2);
110+
});
111+
112+
it('strips leading and trailing slashes from rscRenderingUrlPath', async () => {
113+
const { pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest('/rsc-render/');
114+
115+
pushFirstChunk();
116+
pushSecondChunk();
117+
endStream();
118+
119+
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent'));
120+
expect(window.fetch).toHaveBeenCalledTimes(1);
121+
122+
await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument());
123+
});
124+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"html": "1:\"$Sreact.suspense\"\n0:D{\"name\":\"StaticServerComponent\",\"env\":\"Server\"}\n2:D{\"name\":\"AsyncComponent\",\"env\":\"Server\"}\n0:[\"$\",\"div\",null,{\"children\":[[\"$\",\"h1\",null,{\"children\":\"StaticServerComponent\"}],[\"$\",\"p\",null,{\"children\":\"This is a static server component\"}],[\"$\",\"$1\",null,{\"fallback\":[\"$\",\"div\",null,{\"children\":\"Loading AsyncComponent...\"}],\"children\":\"$L2\"}]]}]\n",
3+
"consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at first chunk\"]);\n\u003c/script\u003e",
4+
"hasErrors": false,
5+
"isShellReady": true
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"html": "2:[\"$\",\"div\",null,{\"children\":\"AsyncComponent\"}]\n",
3+
"consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at second chunk\"]);\n\u003c/script\u003e",
4+
"hasErrors": false,
5+
"isShellReady": true
6+
}

node_package/tests/jest.setup.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ if (typeof window !== 'undefined' && typeof window.MessageChannel !== 'undefined
1313
if (typeof window !== 'undefined') {
1414
// eslint-disable-next-line global-require
1515
const { TextEncoder, TextDecoder } = require('util');
16+
// eslint-disable-next-line global-require
17+
const { Readable } = require('stream');
18+
// eslint-disable-next-line global-require
19+
const { ReadableStream, ReadableStreamDefaultReader } = require('stream/web');
20+
21+
// Mock the fetch function to return a ReadableStream instead of Node's Readable stream
22+
// This matches browser behavior where fetch responses have ReadableStream bodies
23+
// Node's fetch and polyfills like jest-fetch-mock return Node's Readable stream,
24+
// so we convert it to a web-standard ReadableStream for consistency
25+
// Note: Node's Readable stream exists in node 'stream' built-in module, can be imported as `import { Readable } from 'stream'`
26+
jest.mock('../src/utils', () => ({
27+
...jest.requireActual('../src/utils'),
28+
fetch: (...args) => jest.requireActual('../src/utils').fetch(...args).then(res => {
29+
const originalBody = res.body;
30+
if (originalBody instanceof Readable) {
31+
Object.defineProperty(res, 'body', {
32+
value: Readable.toWeb(originalBody),
33+
});
34+
}
35+
return res;
36+
}),
37+
}));
38+
1639
global.TextEncoder = TextEncoder;
1740
global.TextDecoder = TextDecoder;
1841

@@ -32,4 +55,6 @@ if (typeof window !== 'undefined') {
3255
},
3356
};
3457
});
58+
global.ReadableStream = ReadableStream;
59+
global.ReadableStreamDefaultReader = ReadableStreamDefaultReader;
3560
}

node_package/tests/testUtils.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Readable } from 'stream';
2+
3+
/**
4+
* Creates a Node.js Readable stream with external push capability.
5+
* Pusing a null or undefined chunk will end the stream.
6+
* @returns {{
7+
* stream: Readable,
8+
* push: (chunk: any) => void
9+
* }} Object containing the stream and push function
10+
*/
11+
// eslint-disable-next-line import/prefer-default-export
12+
export const createNodeReadableStream = () => {
13+
const pendingChunks = [];
14+
let pushFn;
15+
const stream = new Readable({
16+
read() {
17+
pushFn = this.push.bind(this);
18+
if (pendingChunks.length > 0) {
19+
pushFn(pendingChunks.shift());
20+
}
21+
},
22+
});
23+
24+
const push = (chunk) => {
25+
if (pushFn) {
26+
pushFn(chunk);
27+
} else {
28+
pendingChunks.push(chunk);
29+
}
30+
};
31+
32+
return { stream, push };
33+
};

node_package/tests/utils.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { enableFetchMocks } from 'jest-fetch-mock';
2+
3+
import { Readable } from 'stream';
4+
import { fetch } from '../src/utils';
5+
import { createNodeReadableStream } from './testUtils';
6+
7+
enableFetchMocks();
8+
9+
describe('fetch', () => {
10+
it('streams body as ReadableStream', async () => {
11+
// create Readable stream that emits 5 chunks with 10ms delay between each chunk
12+
const { stream, push } = createNodeReadableStream();
13+
let n = 0;
14+
const intervalId = setInterval(() => {
15+
n += 1;
16+
push(`chunk${n}`);
17+
if (n === 5) {
18+
clearInterval(intervalId);
19+
push(null);
20+
}
21+
}, 10);
22+
23+
global.fetchMock.mockResolvedValue(new Response(stream));
24+
25+
await fetch('/test').then(async (response) => {
26+
console.log(response.body);
27+
const { body } = response;
28+
expect(body).toBeInstanceOf(ReadableStream);
29+
30+
const reader = body.getReader();
31+
const chunks = [];
32+
const decoder = new TextDecoder();
33+
let { done, value } = await reader.read();
34+
while (!done) {
35+
chunks.push(decoder.decode(value));
36+
// eslint-disable-next-line no-await-in-loop
37+
({ done, value } = await reader.read());
38+
}
39+
expect(chunks).toEqual(['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']);
40+
41+
// expect global.fetch to be called one time
42+
expect(global.fetch).toHaveBeenCalledTimes(1);
43+
});
44+
});
45+
});

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@
2121
"@types/node": "^20.17.16",
2222
"@types/react": "^18.3.18",
2323
"@types/react-dom": "^18.3.5",
24+
"@testing-library/dom": "^10.4.0",
25+
"@testing-library/jest-dom": "^6.6.3",
26+
"@testing-library/react": "^16.2.0",
2427
"@types/turbolinks": "^5.2.2",
2528
"@typescript-eslint/eslint-plugin": "^6.18.1",
2629
"@typescript-eslint/parser": "^6.18.1",
2730
"concurrently": "^8.2.2",
2831
"create-react-class": "^15.7.0",
32+
"cross-fetch": "^4.1.0",
2933
"eslint": "^7.32.0",
3034
"eslint-config-prettier": "^7.0.0",
3135
"eslint-config-shakacode": "^16.0.1",
@@ -35,6 +39,8 @@
3539
"eslint-plugin-react": "^7.33.2",
3640
"jest": "^29.7.0",
3741
"jest-environment-jsdom": "^29.7.0",
42+
"jest-fetch-mock": "^3.0.3",
43+
"jsdom": "^22.1.0",
3844
"knip": "^5.43.1",
3945
"nps": "^5.9.3",
4046
"prettier": "^2.8.8",

0 commit comments

Comments
 (0)