Skip to content

Commit dbb0675

Browse files
tmp
1 parent 3cace71 commit dbb0675

File tree

7 files changed

+259
-23
lines changed

7 files changed

+259
-23
lines changed

node_package/src/RSCClientRoot.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
"use client";
2-
31
import * as React from 'react';
42
import RSDWClient from 'react-server-dom-webpack/client';
5-
import { fetch } from './utils';
63
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs';
4+
import { rscStream } from './readRSCOnClient';
75

86
if (!('use' in React && typeof React.use === 'function')) {
97
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.');
@@ -21,20 +19,10 @@ export type RSCClientRootProps = {
2119
rscRenderingUrlPath: string;
2220
}
2321

24-
const createFromFetch = async (fetchPromise: Promise<Response>) => {
25-
const response = await fetchPromise;
26-
const stream = response.body;
27-
if (!stream) {
28-
throw new Error('No stream found in response');
29-
}
30-
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
31-
return RSDWClient.createFromReadableStream(transformedStream);
32-
}
33-
34-
const fetchRSC = ({ componentName, rscRenderingUrlPath }: RSCClientRootProps) => {
22+
const fetchRSC = ({ componentName }: RSCClientRootProps) => {
3523
if (!renderCache[componentName]) {
36-
const strippedUrlPath = rscRenderingUrlPath.replace(/^\/|\/$/g, '');
37-
renderCache[componentName] = createFromFetch(fetch(`/${strippedUrlPath}/${componentName}`)) as Promise<React.ReactNode>;
24+
const transformedStream = transformRSCStreamAndReplayConsoleLogs(rscStream);
25+
renderCache[componentName] = RSDWClient.createFromReadableStream(transformedStream);
3826
}
3927
return renderCache[componentName];
4028
}

node_package/src/injectRSCPayload.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { PassThrough } from 'stream';
2+
3+
// Escape closing script tags and HTML comments in JS content.
4+
// https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements
5+
// Avoid replacing </script with <\/script as it would break the following valid JS: 0</script/ (i.e. regexp literal).
6+
// Instead, escape the s character.
7+
function escapeScript(script) {
8+
return script
9+
.replace(/<!--/g, '<\\!--')
10+
.replace(/<\/(script)/gi, '</\\$1');
11+
}
12+
13+
function writeChunk(chunk, transform) {
14+
transform.push(`<script>${escapeScript(`(self.__FLIGHT_DATA||=[]).push(${chunk})`)}</script>`);
15+
}
16+
17+
export default function injectRSCPayload(pipeableHtmlStream, rscStream) {
18+
const htmlStream = new PassThrough();
19+
pipeableHtmlStream.pipe(htmlStream);
20+
const decoder = new TextDecoder();
21+
let rscPromise = null;
22+
const htmlBuffer = [];
23+
let timeout = null;
24+
const resultStream = new PassThrough();
25+
26+
// Start reading RSC stream immediately
27+
const startRSC = async () => {
28+
try {
29+
for await (const chunk of rscStream) {
30+
try {
31+
writeChunk(JSON.stringify(decoder.decode(chunk)), resultStream);
32+
} catch (err) {
33+
const base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk)));
34+
writeChunk(`Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`, resultStream);
35+
}
36+
}
37+
} catch (err) {
38+
resultStream.emit('error', err);
39+
}
40+
};
41+
42+
const writeHTMLChunks = () => {
43+
for (const htmlChunk of htmlBuffer) {
44+
resultStream.push(htmlChunk);
45+
}
46+
htmlBuffer.length = 0;
47+
}
48+
49+
htmlStream.on('data', (chunk) => {
50+
const buf = decoder.decode(chunk);
51+
htmlBuffer.push(buf);
52+
if (timeout) {
53+
return;
54+
}
55+
56+
timeout = setTimeout(() => {
57+
writeHTMLChunks();
58+
if (!rscPromise) {
59+
rscPromise = startRSC();
60+
}
61+
timeout = null;
62+
}, 0);
63+
});
64+
65+
htmlStream.on('end', () => {
66+
if (timeout) {
67+
clearTimeout(timeout);
68+
}
69+
writeHTMLChunks();
70+
if (!rscPromise) {
71+
rscPromise = startRSC();
72+
}
73+
rscPromise.then(() => resultStream.end()).catch(err => resultStream.emit('error', err));
74+
});
75+
76+
return resultStream;
77+
}

node_package/src/mergeTwoStreams.js

Whitespace-only changes.

node_package/src/readRSCOnClient.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* eslint-disable no-restricted-globals */
2+
/* eslint-disable no-underscore-dangle */
3+
const encoder = new TextEncoder();
4+
let streamController;
5+
// eslint-disable-next-line import/prefer-default-export
6+
export const rscStream = new ReadableStream({
7+
start(controller) {
8+
if (typeof window === 'undefined') {
9+
return;
10+
}
11+
const handleChunk = chunk => {
12+
if (typeof chunk === 'string') {
13+
controller.enqueue(encoder.encode(chunk));
14+
} else {
15+
controller.enqueue(chunk);
16+
}
17+
};
18+
if (!window.__FLIGHT_DATA) {
19+
window.__FLIGHT_DATA = [];
20+
}
21+
window.__FLIGHT_DATA.forEach(handleChunk);
22+
window.__FLIGHT_DATA.push = (chunk) => {
23+
handleChunk(chunk);
24+
};
25+
streamController = controller;
26+
},
27+
});
28+
29+
if (typeof document !== 'undefined' && document.readyState === 'loading') {
30+
document.addEventListener('DOMContentLoaded', () => {
31+
streamController?.close();
32+
});
33+
} else {
34+
streamController?.close();
35+
}

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ReactDOMServer, { type PipeableStream } from 'react-dom/server';
22
import { PassThrough, Readable } from 'stream';
33
import type { ReactElement } from 'react';
4+
import injectRSCPayload from './injectRSCPayload';
45

56
import ComponentRegistry from './ComponentRegistry';
67
import createReactOutput from './createReactOutput';
@@ -34,8 +35,8 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
3435
}
3536
});
3637

37-
let pipedStream: PipeableStream | null = null;
38-
const pipeToTransform = (pipeableStream: PipeableStream) => {
38+
let pipedStream: PipeableStream | PassThrough | null = null;
39+
const pipeToTransform = (pipeableStream: PipeableStream | PassThrough) => {
3940
pipeableStream.pipe(transformStream);
4041
pipedStream = pipeableStream;
4142
};
@@ -50,13 +51,17 @@ export const transformRenderStreamChunksToResultObject = (renderState: StreamRen
5051
const emitError = (error: unknown) => readableStream.emit('error', error);
5152
const endStream = () => {
5253
transformStream.end();
53-
pipedStream?.abort();
54+
if (pipedStream && 'end' in pipedStream) {
55+
pipedStream.end();
56+
} else if (pipedStream) {
57+
pipedStream.abort();
58+
}
5459
}
5560
return { readableStream, pipeToTransform, writeChunk, emitError, endStream };
5661
}
5762

5863
const streamRenderReactComponent = (reactRenderingResult: ReactElement, options: StreamRenderParams) => {
59-
const { name: componentName, throwJsErrors } = options;
64+
const { name: componentName, throwJsErrors, rscResult } = options;
6065
const renderState: StreamRenderState = {
6166
result: null,
6267
hasErrors: false,
@@ -87,7 +92,11 @@ const streamRenderReactComponent = (reactRenderingResult: ReactElement, options:
8792
},
8893
onShellReady() {
8994
renderState.isShellReady = true;
90-
pipeToTransform(renderingStream);
95+
if (rscResult) {
96+
pipeToTransform(injectRSCPayload(renderingStream, rscResult));
97+
} else {
98+
pipeToTransform(renderingStream);
99+
}
91100
},
92101
onError(e) {
93102
if (!renderState.isShellReady) {
@@ -147,9 +156,18 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
147156

148157
const streamServerRenderedReactComponent = (options: StreamRenderParams): Readable => {
149158
const { rscResult, reactClientManifestFileName, reactServerManifestFileName } = options;
159+
let rscResult1;
160+
let rscResult2;
161+
if (typeof rscResult === 'object') {
162+
rscResult1 = new PassThrough();
163+
rscResult.pipe(rscResult1);
164+
rscResult2 = new PassThrough();
165+
rscResult.pipe(rscResult2);
166+
}
150167
return streamServerRenderedComponent({
151168
...options,
152-
props: { ...options.props, getRscPromise: rscResult, reactClientManifestFileName, reactServerManifestFileName }
169+
rscResult: rscResult1,
170+
props: { ...options.props, getRscPromise: rscResult2, reactClientManifestFileName, reactServerManifestFileName }
153171
}, streamRenderReactComponent);
154172
}
155173

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Readable, PassThrough } from 'stream';
2+
import injectRSCPayload from '../src/injectRSCPayload';
3+
4+
describe('injectRSCPayload', () => {
5+
const createMockStream = (chunks) => {
6+
if (Array.isArray(chunks)) {
7+
return Readable.from(chunks.map(chunk =>
8+
typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk
9+
));
10+
}
11+
const passThrough = new PassThrough();
12+
const entries = Object.entries(chunks);
13+
const keysLength = entries.length;
14+
entries.forEach(([delay, value], index) => {
15+
setTimeout(() => {
16+
const chunksArray = Array.isArray(value) ? value : [value];
17+
chunksArray.forEach(chunk => {
18+
passThrough.push(new TextEncoder().encode(chunk));
19+
});
20+
if (index === keysLength - 1) {
21+
passThrough.push(null);
22+
}
23+
}, +delay);
24+
});
25+
return passThrough;
26+
}
27+
28+
const collectStreamData = async (stream) => {
29+
const chunks = [];
30+
for await (const chunk of stream) {
31+
chunks.push(new TextDecoder().decode(chunk));
32+
}
33+
return chunks.join('');
34+
};
35+
36+
it('should inject RSC payload as script tags', async () => {
37+
const mockRSC = createMockStream(['{"test": "data"}']);
38+
const mockHTML = createMockStream(['<html><body><div>Hello, world!</div></body></html>']);
39+
const result = injectRSCPayload(mockHTML, mockRSC);
40+
const resultStr = await collectStreamData(result);
41+
42+
expect(resultStr).toContain(
43+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data\\"}")</script>'
44+
);
45+
});
46+
47+
it('should handle multiple RSC payloads', async () => {
48+
const mockRSC = createMockStream(['{"test": "data"}', '{"test": "data2"}']);
49+
const mockHTML = createMockStream(['<html><body><div>Hello, world!</div></body></html>']);
50+
const result = injectRSCPayload(mockHTML, mockRSC);
51+
const resultStr = await collectStreamData(result);
52+
53+
expect(resultStr).toContain(
54+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data\\"}")</script>'
55+
);
56+
expect(resultStr).toContain(
57+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data2\\"}")</script>'
58+
);
59+
});
60+
61+
it('should add all ready html chunks before adding RSC payloads', async () => {
62+
const mockRSC = createMockStream(['{"test": "data"}', '{"test": "data2"}']);
63+
const mockHTML = createMockStream([
64+
'<html><body><div>Hello, world!</div></body></html>',
65+
'<div>Next chunk</div>',
66+
]);
67+
const result = injectRSCPayload(mockHTML, mockRSC);
68+
const resultStr = await collectStreamData(result);
69+
70+
expect(resultStr).toEqual(
71+
'<html><body><div>Hello, world!</div></body></html>' +
72+
'<div>Next chunk</div>' +
73+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data\\"}")</script>' +
74+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data2\\"}")</script>'
75+
);
76+
});
77+
78+
it('adds delayed html chunks after RSC payloads', async () => {
79+
const mockRSC = createMockStream(['{"test": "data"}', '{"test": "data2"}']);
80+
const mockHTML = createMockStream({
81+
0: '<html><body><div>Hello, world!</div></body></html>',
82+
100: '<div>Next chunk</div>'
83+
});
84+
const result = injectRSCPayload(mockHTML, mockRSC);
85+
const resultStr = await collectStreamData(result);
86+
87+
expect(resultStr).toEqual(
88+
'<html><body><div>Hello, world!</div></body></html>' +
89+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data\\"}")</script>' +
90+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data2\\"}")</script>' +
91+
'<div>Next chunk</div>'
92+
);
93+
});
94+
95+
it('handles the case when html is delayed', async () => {
96+
const mockRSC = createMockStream({
97+
0: '{"test": "data"}',
98+
150: '{"test": "data2"}',
99+
});
100+
const mockHTML = createMockStream({
101+
100: [
102+
'<html><body><div>Hello, world!</div></body></html>',
103+
'<div>Next chunk</div>'
104+
],
105+
200: '<div>Third chunk</div>'
106+
});
107+
const result = injectRSCPayload(mockHTML, mockRSC);
108+
const resultStr = await collectStreamData(result);
109+
110+
expect(resultStr).toEqual(
111+
'<html><body><div>Hello, world!</div></body></html>' +
112+
'<div>Next chunk</div>' +
113+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data\\"}")</script>' +
114+
'<script>(self.__FLIGHT_DATA||=[]).push("{\\"test\\": \\"data2\\"}")</script>' +
115+
'<div>Third chunk</div>'
116+
);
117+
});
118+
});

spec/dummy/Procfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Procfile for development using HMR
22
# You can run these commands in separate shells
3-
rails: bundle exec rails s -p 3000
3+
# rails: bundle exec rails s -p 3000
44
wp-client: bin/shakapacker-dev-server
55
wp-server: SERVER_BUNDLE_ONLY=true bin/shakapacker --watch
66

0 commit comments

Comments
 (0)