Skip to content

Commit 19a01db

Browse files
authored
Revive Pro streaming validation coverage (#4035)
Revive Pro streaming validation coverage.\n\nValidated current-head hosted checks, unresolved review threads, local focused tests, and merge-monitor coordination before delegated squash merge.
1 parent abdce4a commit 19a01db

5 files changed

Lines changed: 177 additions & 80 deletions

File tree

packages/react-on-rails-pro/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
"build": "pnpm run clean && tsc",
99
"build-watch": "pnpm run clean && tsc --watch",
1010
"clean": "rm -rf ./lib",
11-
"test": "pnpm run test:non-rsc && pnpm run test:rsc",
12-
"test:non-rsc": "jest tests --testPathIgnorePatterns=\".*(RSC|stream|registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"",
11+
"test": "pnpm run test:non-rsc && pnpm run test:streaming && pnpm run test:rsc",
12+
"test:non-rsc": "jest tests --testPathIgnorePatterns=\"tests/.*(RSC|stream|registerServerComponent|serverRenderReactComponent|SuspenseHydration).*\"",
13+
"test:streaming": "node scripts/check-react-version.cjs || jest tests/streamServerRenderedReactComponent.test.jsx tests/streamBackpressure.e2e.test.tsx tests/injectRSCPayload.test.ts tests/SuspenseHydration.test.tsx tests/parseLengthPrefixedStream.test.ts --runInBand",
1314
"test:rsc": "node scripts/check-react-version.cjs || NODE_CONDITIONS=react-server jest tests/*.rsc.test.*",
1415
"type-check": "tsc --noEmit --noErrorTruncation",
1516
"prepare": "[ -f lib/ReactOnRails.full.js ] || (rm -rf ./lib && tsc)",

packages/react-on-rails-pro/tests/SuspenseHydration.test.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515

1616
import * as React from 'react';
1717
import { Suspense } from 'react';
18-
import { renderToReadableStream } from 'react-dom/server';
18+
import { renderToReadableStream } from 'react-dom/server.browser';
1919
import { hydrateRoot } from 'react-dom/client';
2020
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
2121
// @ts-ignore - TypeScript error can be ignored because:
2222
// 1. This test file is only executed when Node version >= 18
2323
// 2. The package is guaranteed to be available at runtime in Node 18+ environments
2424
import { screen, act, waitFor } from '@testing-library/react';
2525
import '@testing-library/jest-dom';
26-
import { getNodeVersion } from './testUtils.js';
26+
import { getNodeVersion } from './testUtils';
2727

2828
/**
2929
* Tests React's Suspense hydration behavior for async components
@@ -135,18 +135,27 @@ async function renderAndHydrate() {
135135
);
136136

137137
const reader = stream.getReader();
138+
const readNextChunk = async () => {
139+
const { done, value } = await reader.read();
140+
if (done) {
141+
throw new Error('Expected another streamed HTML chunk before the stream ended.');
142+
}
143+
144+
return new TextDecoder().decode(value);
145+
};
146+
138147
const writeFirstChunk = async () => {
139-
const result = await reader.read();
140-
const decoded = new TextDecoder().decode(result.value as Buffer);
148+
const decoded = await readNextChunk();
141149
container.innerHTML = decoded;
142150
return decoded;
143151
};
144152

145153
const writeSecondChunk = async () => {
154+
// Assert at least one more chunk exists, then drain any remaining chunks.
155+
let decoded = await readNextChunk();
146156
let { done, value } = await reader.read();
147-
let decoded = '';
148157
while (!done) {
149-
decoded += new TextDecoder().decode(value as Buffer);
158+
decoded += new TextDecoder().decode(value);
150159
// eslint-disable-next-line no-await-in-loop
151160
({ done, value } = await reader.read());
152161
}
@@ -166,8 +175,8 @@ async function renderAndHydrate() {
166175
};
167176
}
168177

169-
// React Server Components tests require React 19 and only run with Node version 18 (`newest` in our CI matrix)
170-
(getNodeVersion() >= 18 ? describe : describe.skip)('RSCClientRoot', () => {
178+
// The package `test:streaming` script skips React < 19; this guard also skips older Node CI lanes.
179+
(getNodeVersion() >= 18 ? describe : describe.skip)('SuspenseHydration', () => {
171180
beforeEach(() => {
172181
jest.clearAllMocks();
173182
document.body.innerHTML = '';

packages/react-on-rails-pro/tests/streamBackpressure.e2e.test.tsx

Lines changed: 131 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ import { PassThrough } from 'stream';
3838
import streamServerRenderedReactComponent from '../src/streamServerRenderedReactComponent.ts';
3939
import * as ComponentRegistry from '../src/ComponentRegistry.ts';
4040
import ReactOnRails from '../src/ReactOnRails.node.ts';
41+
import LengthPrefixedStreamParser from '../src/parseLengthPrefixedStream.ts';
4142

42-
const HIGHWATER_MARK = 16 * 1024; // Node.js default PassThrough highWaterMark: 16KB
43+
const INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING = '[react_on_rails] Incomplete length-prefixed stream';
4344

4445
const testingRailsContext = {
4546
serverSideRSCPayloadParameters: {},
@@ -48,37 +49,129 @@ const testingRailsContext = {
4849
componentSpecificMetadata: {
4950
renderRequestId: '123',
5051
},
51-
} as any;
52+
};
5253

53-
// Collect all JSON chunks from the result stream into parsed objects.
54-
// streamServerRenderedReactComponent emits JSON objects: {html, consoleReplayScript, hasErrors, isShellReady}
55-
const collectChunks = (
56-
stream: NodeJS.ReadableStream,
57-
): Promise<{ html: string; hasErrors: boolean; isShellReady: boolean }[]> =>
54+
type RailsContextWithRSCPayloadStream = typeof testingRailsContext & {
55+
getRSCPayloadStream: (
56+
componentName: string,
57+
props: Record<string, unknown>,
58+
) => Promise<AsyncIterable<Buffer>>;
59+
};
60+
61+
const toLengthPrefixedPayload = (content: string): Buffer => {
62+
const contentBuffer = Buffer.from(content, 'utf8');
63+
const metadata = JSON.stringify({ consoleReplayScript: '', hasErrors: false, isShellReady: true });
64+
return Buffer.concat([
65+
Buffer.from(`${metadata}\t${contentBuffer.length.toString(16).padStart(8, '0')}\n`, 'utf8'),
66+
contentBuffer,
67+
]);
68+
};
69+
70+
const expectRSCPayloadPushScript = (html: string) => {
71+
expect(html).toMatch(/REACT_ON_RAILS_RSC_PAYLOADS[^<]*\.push\(/);
72+
};
73+
74+
type StreamResultChunk = {
75+
html: string;
76+
consoleReplayScript: string;
77+
hasErrors: boolean;
78+
isShellReady: boolean;
79+
};
80+
81+
// Collect all length-prefixed chunks from the result stream into parsed objects.
82+
// streamServerRenderedReactComponent emits: <metadata JSON>\t<content byte length hex>\n<raw html bytes>
83+
const collectChunks = (stream: NodeJS.ReadableStream): Promise<StreamResultChunk[]> =>
5884
new Promise((resolve, reject) => {
59-
const chunks: { html: string; hasErrors: boolean; isShellReady: boolean }[] = [];
85+
const chunks: StreamResultChunk[] = [];
86+
const parser = new LengthPrefixedStreamParser();
87+
const decoder = new TextDecoder();
88+
89+
const flushParserOrThrow = () => {
90+
// Capture warnings manually to preserve any outer console.warn spy after flush().
91+
const warnings: unknown[][] = [];
92+
const originalWarn = console.warn;
93+
console.warn = (...args) => {
94+
warnings.push(args);
95+
};
96+
97+
try {
98+
parser.flush();
99+
const incompleteStreamWarning = warnings.find(([message]) =>
100+
String(message).includes(INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING),
101+
);
102+
103+
if (incompleteStreamWarning) {
104+
throw new Error(String(incompleteStreamWarning[0]));
105+
}
106+
} finally {
107+
// flush() is synchronous, so restoring here preserves any outer spy safely.
108+
console.warn = originalWarn;
109+
}
110+
};
111+
60112
stream.on('data', (chunk: Buffer) => {
61-
const text = new TextDecoder().decode(chunk);
62-
// A single data event may contain multiple JSON objects separated by newlines
63-
for (const line of text.split('\n').filter(Boolean)) {
64-
chunks.push(JSON.parse(line));
113+
try {
114+
parser.feed(chunk, (content, metadata) => {
115+
chunks.push({
116+
html: decoder.decode(content),
117+
...metadata,
118+
} as StreamResultChunk);
119+
});
120+
} catch (error) {
121+
reject(error instanceof Error ? error : new Error(String(error)));
122+
}
123+
});
124+
stream.on('end', () => {
125+
try {
126+
flushParserOrThrow();
127+
resolve(chunks);
128+
} catch (error) {
129+
reject(error instanceof Error ? error : new Error(String(error)));
65130
}
66131
});
67-
stream.on('end', () => resolve(chunks));
68132
stream.on('error', reject);
69133
});
70134

135+
describe('collectChunks - length-prefixed stream parsing', () => {
136+
it('preserves an outer console.warn spy while checking parser flush warnings', async () => {
137+
const completeStream = new PassThrough();
138+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
139+
const result = collectChunks(completeStream);
140+
141+
try {
142+
completeStream.push(toLengthPrefixedPayload('complete payload'));
143+
completeStream.push(null);
144+
145+
await expect(result).resolves.toHaveLength(1);
146+
expect(jest.isMockFunction(console.warn)).toBe(true);
147+
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
148+
expect.stringContaining(INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING),
149+
);
150+
} finally {
151+
consoleWarnSpy.mockRestore();
152+
}
153+
});
154+
155+
it('rejects if the stream ends with an incomplete length-prefixed chunk', async () => {
156+
const truncatedStream = new PassThrough();
157+
const fullChunk = toLengthPrefixedPayload('truncated payload');
158+
const result = collectChunks(truncatedStream);
159+
160+
truncatedStream.push(fullChunk.subarray(0, fullChunk.length - 1));
161+
truncatedStream.push(null);
162+
163+
await expect(result).rejects.toThrow(INCOMPLETE_LENGTH_PREFIXED_STREAM_WARNING);
164+
});
165+
});
166+
71167
describe('streamServerRenderedReactComponent - RSC payload exceeding default highWaterMark (e2e)', () => {
72168
let source: PassThrough;
169+
let generateRSCPayload: jest.Mock<Promise<PassThrough>, [string, unknown, unknown]>;
73170

74171
beforeEach(() => {
75172
ComponentRegistry.clear();
76173
source = new PassThrough();
77-
(globalThis as any).generateRSCPayload = jest.fn().mockResolvedValue(source);
78-
});
79-
80-
afterEach(() => {
81-
delete (globalThis as any).generateRSCPayload;
174+
generateRSCPayload = jest.fn().mockResolvedValue(source);
82175
});
83176

84177
const renderComponent = (name: string) =>
@@ -89,7 +182,8 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
89182
props: {},
90183
throwJsErrors: true,
91184
railsContext: testingRailsContext,
92-
} as any);
185+
generateRSCPayload,
186+
} as unknown as Parameters<typeof streamServerRenderedReactComponent>[0]);
93187

94188
// Helper: register a render function whose returned Promise reads ALL data from the RSC
95189
// payload stream before resolving to a React element. This simulates what
@@ -101,7 +195,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
101195
// that triggers backpressure issues when the payload exceeds stream2's buffer capacity.
102196
const registerRSCRenderFunction = (name: string) => {
103197
ReactOnRails.register({
104-
[name]: (_props: Record<string, unknown>, railsContext: any) =>
198+
[name]: (_props: Record<string, unknown>, railsContext: RailsContextWithRSCPayloadStream) =>
105199
railsContext
106200
.getRSCPayloadStream('ServerComponent', _props)
107201
.then(async (rscStream: AsyncIterable<Buffer>) => {
@@ -117,14 +211,14 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
117211
`RSC payload: ${totalBytes} bytes`,
118212
);
119213
}),
120-
});
214+
} as unknown as Parameters<typeof ReactOnRails.register>[0]);
121215
};
122216

123217
it('completes with RSC payload scripts for payloads under the default highWaterMark', async () => {
124218
registerRSCRenderFunction('SmallRSCComponent');
125219

126220
// Push a small payload — fits within stream2's buffer, no backpressure risk
127-
const payload = 'x'.repeat(1024);
221+
const payload = toLengthPrefixedPayload('x'.repeat(1024));
128222
source.push(payload);
129223
source.push(null);
130224

@@ -135,11 +229,12 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
135229

136230
// Verify the component rendered with the RSC data
137231
expect(allHtml).toContain('rsc-content');
138-
expect(allHtml).toContain('RSC payload: 1024 bytes');
232+
// payload.length includes the length-prefix framing overhead (header + hex + newline + content)
233+
expect(allHtml).toContain(`RSC payload: ${payload.length} bytes`);
139234

140235
// Verify RSC payload initialization and data scripts are embedded
141236
expect(allHtml).toContain('REACT_ON_RAILS_RSC_PAYLOADS');
142-
expect(allHtml).toContain('.push(');
237+
expectRSCPayloadPushScript(allHtml);
143238
});
144239

145240
// Tests the edge case where the RSC Flight payload significantly exceeds the default
@@ -155,9 +250,9 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
155250
// (16KB readable + 16KB writable).
156251
const chunkSize = 1024;
157252
const chunkCount = 128;
158-
const totalBytes = chunkSize * chunkCount;
159-
const chunk = Buffer.alloc(chunkSize, 0x61); // fill with 'a'
160-
for (let i = 0; i < chunkCount; i++) {
253+
const chunk = toLengthPrefixedPayload('a'.repeat(chunkSize));
254+
const totalBytes = chunk.length * chunkCount;
255+
for (let i = 0; i < chunkCount; i += 1) {
161256
source.push(chunk);
162257
}
163258
source.push(null);
@@ -170,7 +265,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
170265
expect(allHtml).toContain('rsc-content');
171266
expect(allHtml).toContain(`RSC payload: ${totalBytes} bytes`);
172267
expect(allHtml).toContain('REACT_ON_RAILS_RSC_PAYLOADS');
173-
expect(allHtml).toContain('.push(');
268+
expectRSCPayloadPushScript(allHtml);
174269
}, 5000);
175270

176271
// Same as above but with data pushed asynchronously — more closely simulates a real
@@ -185,8 +280,8 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
185280
// with React rendering and event loop ticks.
186281
const chunkSize = 1024;
187282
const chunkCount = 128;
188-
const totalBytes = chunkSize * chunkCount;
189-
const chunk = Buffer.alloc(chunkSize, 0x62); // fill with 'b'
283+
const chunk = toLengthPrefixedPayload('b'.repeat(chunkSize));
284+
const totalBytes = chunk.length * chunkCount;
190285
let pushed = 0;
191286
const pushInterval = setInterval(() => {
192287
if (pushed >= chunkCount) {
@@ -195,7 +290,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
195290
return;
196291
}
197292
source.push(chunk);
198-
pushed++;
293+
pushed += 1;
199294
}, 1);
200295

201296
const chunks = await collectChunks(renderResult);
@@ -204,6 +299,7 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
204299
expect(allHtml).toContain('rsc-content');
205300
expect(allHtml).toContain(`RSC payload: ${totalBytes} bytes`);
206301
expect(allHtml).toContain('REACT_ON_RAILS_RSC_PAYLOADS');
302+
expectRSCPayloadPushScript(allHtml);
207303
}, 10000);
208304

209305
// Tests the boundary condition: payload just above ~32KB combined buffer capacity.
@@ -214,9 +310,9 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
214310
// Push exactly 48KB — just above the ~32KB combined buffer capacity.
215311
const chunkSize = 1024;
216312
const chunkCount = 48;
217-
const totalBytes = chunkSize * chunkCount;
218-
const chunk = Buffer.alloc(chunkSize, 0x63); // fill with 'c'
219-
for (let i = 0; i < chunkCount; i++) {
313+
const chunk = toLengthPrefixedPayload('c'.repeat(chunkSize));
314+
const totalBytes = chunk.length * chunkCount;
315+
for (let i = 0; i < chunkCount; i += 1) {
220316
source.push(chunk);
221317
}
222318
source.push(null);
@@ -229,5 +325,6 @@ describe('streamServerRenderedReactComponent - RSC payload exceeding default hig
229325
expect(allHtml).toContain('rsc-content');
230326
expect(allHtml).toContain(`RSC payload: ${totalBytes} bytes`);
231327
expect(allHtml).toContain('REACT_ON_RAILS_RSC_PAYLOADS');
328+
expectRSCPayloadPushScript(allHtml);
232329
}, 5000);
233330
});

0 commit comments

Comments
 (0)