Skip to content

Commit

Permalink
add tests for RSCClientRoot
Browse files Browse the repository at this point in the history
  • Loading branch information
AbanoubGhadban committed Jan 25, 2025
1 parent c7a2731 commit e1fdfc7
Show file tree
Hide file tree
Showing 11 changed files with 524 additions and 11 deletions.
6 changes: 0 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,6 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
webpacker (6.0.0.rc.6)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket (1.2.10)
websocket-driver (0.7.6)
Expand Down Expand Up @@ -444,7 +439,6 @@ DEPENDENCIES
turbolinks
uglifier
webdrivers (= 5.3.0)
webpacker (= 6.0.0.rc.6)

BUNDLED WITH
2.5.9
8 changes: 6 additions & 2 deletions node_package/src/RSCClientRoot.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import * as React from 'react';
import RSDWClient from 'react-server-dom-webpack/client';
import { fetch } from './utils';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';

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

const { use } = React;

const renderCache: Record<string, Promise<React.ReactNode>> = {};
let renderCache: Record<string, Promise<React.ReactNode>> = {};
export const resetRenderCache = () => {
renderCache = {};
}

export type RSCClientRootProps = {
componentName: string;
Expand Down
8 changes: 8 additions & 0 deletions node_package/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Override the fetch function to make it easier to test and for future use
const customFetch = (...args: Parameters<typeof fetch>) => {
const res = fetch(...args);
return res;
}

// eslint-disable-next-line import/prefer-default-export
export { customFetch as fetch };
124 changes: 124 additions & 0 deletions node_package/tests/RSCClientRoot.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable import/first */
/**
* @jest-environment jsdom
*/

// Mock webpack require system for RSC
window.__webpack_require__ = jest.fn();
window.__webpack_chunk_load__ = jest.fn();

import * as React from 'react';
import { enableFetchMocks } from 'jest-fetch-mock';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import path from 'path';
import fs from 'fs';
import { createNodeReadableStream } from './testUtils';

// const __filename = fileURLToPath(import.meta.url);
// const __dirname = path.dirname(__filename);

import RSCClientRoot, {resetRenderCache } from '../src/RSCClientRoot';

enableFetchMocks();

describe('RSCClientRoot', () => {
beforeEach(() => {
jest.clearAllMocks();

jest.resetModules();
resetRenderCache();
});

it('throws error when React.use is not defined', () => {
jest.mock('react', () => ({
...jest.requireActual('react'),
use: undefined
}));

expect(() => {
// Re-import to trigger the check
jest.requireActual('../src/RSCClientRoot');
}).toThrow('React.use is not defined');
});

const mockRSCRequest = (rscRenderingUrlPath = 'rsc-render') => {
const chunksDirectory = path.join(__dirname, 'fixtures', 'rsc-payloads', 'simple-shell-with-async-component');
const chunk1 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk1.json'), 'utf8'));
const chunk2 = JSON.parse(fs.readFileSync(path.join(chunksDirectory, 'chunk2.json'), 'utf8'));

const { stream, push } = createNodeReadableStream();
window.fetchMock.mockResolvedValue(new Response(stream));

const props = {
componentName: 'TestComponent',
rscRenderingUrlPath
};

const { rerender } = render(<RSCClientRoot {...props} />);

return {
rerender: () => rerender(<RSCClientRoot {...props} />),
pushFirstChunk: () => push(JSON.stringify(chunk1)),
pushSecondChunk: () => push(JSON.stringify(chunk2)),
pushCustomChunk: (chunk) => push(chunk),
endStream: () => push(null),
}
}

it('fetches and caches component data', async () => {
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest();

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

pushFirstChunk();
await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument());
expect(screen.getByText('Loading AsyncComponent...')).toBeInTheDocument();
expect(screen.queryByText('AsyncComponent')).not.toBeInTheDocument();

pushSecondChunk();
endStream();
await waitFor(() => expect(screen.getByText('AsyncComponent')).toBeInTheDocument());
expect(screen.queryByText('Loading AsyncComponent...')).not.toBeInTheDocument();

// Second render - should use cache
rerender();

expect(screen.getByText('AsyncComponent')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledTimes(1);
});

it('replays console logs', async () => {
const consoleSpy = jest.spyOn(console, 'log');
const { rerender, pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest();

pushFirstChunk();
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at first chunk'));
expect(consoleSpy).toHaveBeenCalledTimes(1);

pushSecondChunk();
await waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('[SERVER] Console log at second chunk'));
endStream();
expect(consoleSpy).toHaveBeenCalledTimes(2);

// On rerender, console logs should not be replayed again
rerender();
expect(consoleSpy).toHaveBeenCalledTimes(2);
});

it('strips leading and trailing slashes from rscRenderingUrlPath', async () => {
const { pushFirstChunk, pushSecondChunk, endStream } = mockRSCRequest('/rsc-render/');

pushFirstChunk();
pushSecondChunk();
endStream();

await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('/rsc-render/TestComponent'));
expect(window.fetch).toHaveBeenCalledTimes(1);

await waitFor(() => expect(screen.getByText('StaticServerComponent')).toBeInTheDocument());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"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",
"consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at first chunk\"]);\n\u003c/script\u003e",
"hasErrors": false,
"isShellReady": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"html": "2:[\"$\",\"div\",null,{\"children\":\"AsyncComponent\"}]\n",
"consoleReplayScript": "\n\u003cscript id=\"consoleReplayLog\"\u003e\nconsole.log.apply(console, [\"[SERVER] Console log at second chunk\"]);\n\u003c/script\u003e",
"hasErrors": false,
"isShellReady": true
}
25 changes: 25 additions & 0 deletions node_package/tests/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ if (typeof window !== 'undefined' && typeof window.MessageChannel !== 'undefined
if (typeof window !== 'undefined') {
// eslint-disable-next-line global-require
const { TextEncoder, TextDecoder } = require('util');
// eslint-disable-next-line global-require
const { Readable } = require('stream');
// eslint-disable-next-line global-require
const { ReadableStream, ReadableStreamDefaultReader } = require('stream/web');

// Mock the fetch function to return a ReadableStream instead of Node's Readable stream
// This matches browser behavior where fetch responses have ReadableStream bodies
// Node's fetch and polyfills like jest-fetch-mock return Node's Readable stream,
// so we convert it to a web-standard ReadableStream for consistency
// Note: Node's Readable stream exists in node 'stream' built-in module, can be imported as `import { Readable } from 'stream'`
jest.mock('../src/utils', () => ({
...jest.requireActual('../src/utils'),
fetch: (...args) => jest.requireActual('../src/utils').fetch(...args).then(res => {
const originalBody = res.body;
if (originalBody instanceof Readable) {
Object.defineProperty(res, 'body', {
value: Readable.toWeb(originalBody),
});
}
return res;
}),
}));

global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;

Expand All @@ -32,4 +55,6 @@ if (typeof window !== 'undefined') {
},
};
});
global.ReadableStream = ReadableStream;
global.ReadableStreamDefaultReader = ReadableStreamDefaultReader;
}
33 changes: 33 additions & 0 deletions node_package/tests/testUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Readable } from 'stream';

/**
* Creates a Node.js Readable stream with external push capability.
* Pusing a null or undefined chunk will end the stream.
* @returns {{
* stream: Readable,
* push: (chunk: any) => void
* }} Object containing the stream and push function
*/
// eslint-disable-next-line import/prefer-default-export
export const createNodeReadableStream = () => {
const pendingChunks = [];
let pushFn;
const stream = new Readable({
read() {
pushFn = this.push.bind(this);
if (pendingChunks.length > 0) {
pushFn(pendingChunks.shift());
}
},
});

const push = (chunk) => {
if (pushFn) {
pushFn(chunk);
} else {
pendingChunks.push(chunk);
}
};

return { stream, push };
};
45 changes: 45 additions & 0 deletions node_package/tests/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { enableFetchMocks } from 'jest-fetch-mock';

import { Readable } from 'stream';
import { fetch } from '../src/utils';
import { createNodeReadableStream } from './testUtils';

enableFetchMocks();

describe('fetch', () => {
it('streams body as ReadableStream', async () => {
// create Readable stream that emits 5 chunks with 10ms delay between each chunk
const { stream, push } = createNodeReadableStream();
let n = 0;
const intervalId = setInterval(() => {
n += 1;
push(`chunk${n}`);
if (n === 5) {
clearInterval(intervalId);
push(null);
}
}, 10);

global.fetchMock.mockResolvedValue(new Response(stream));

await fetch('/test').then(async (response) => {
console.log(response.body);
const { body } = response;
expect(body).toBeInstanceOf(ReadableStream);

const reader = body.getReader();
const chunks = [];
const decoder = new TextDecoder();
let { done, value } = await reader.read();
while (!done) {
chunks.push(decoder.decode(value));
// eslint-disable-next-line no-await-in-loop
({ done, value } = await reader.read());
}
expect(chunks).toEqual(['chunk1', 'chunk2', 'chunk3', 'chunk4', 'chunk5']);

// expect global.fetch to be called one time
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
});
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@
"@types/node": "^20.17.16",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/turbolinks": "^5.2.2",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"concurrently": "^8.2.2",
"create-react-class": "^15.7.0",
"cross-fetch": "^4.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^7.0.0",
"eslint-config-shakacode": "^16.0.1",
Expand All @@ -35,6 +39,8 @@
"eslint-plugin-react": "^7.33.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"jsdom": "^22.1.0",
"knip": "^5.43.1",
"nps": "^5.9.3",
"prettier": "^2.8.8",
Expand Down
Loading

0 comments on commit e1fdfc7

Please sign in to comment.