From 55acf47f17059443014f8f07dc81039a7ab4d0ab Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 30 Oct 2025 18:12:31 +0300 Subject: [PATCH 1/5] add concurrency tests and check logs leakage --- packages/react-on-rails-pro/package.json | 6 +- .../react-on-rails-pro/tests/AsyncQueue.ts | 59 +++++++ .../react-on-rails-pro/tests/StreamReader.ts | 33 ++++ ...oncurrentRSCPayloadGeneration.rsc.test.tsx | 165 ++++++++++++++++++ ...serverRenderRSCReactComponent.rsc.test.tsx | 98 +++++++++++ packages/react-on-rails/src/types/index.ts | 1 - yarn.lock | 24 ++- 7 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 packages/react-on-rails-pro/tests/AsyncQueue.ts create mode 100644 packages/react-on-rails-pro/tests/StreamReader.ts create mode 100644 packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx create mode 100644 packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index c09a9ef077..3fb75bbf50 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -75,5 +75,9 @@ "bugs": { "url": "https://github.com/shakacode/react_on_rails/issues" }, - "homepage": "https://github.com/shakacode/react_on_rails#readme" + "homepage": "https://github.com/shakacode/react_on_rails#readme", + "devDependencies": { + "@types/mock-fs": "^4.13.4", + "mock-fs": "^5.5.0" + } } diff --git a/packages/react-on-rails-pro/tests/AsyncQueue.ts b/packages/react-on-rails-pro/tests/AsyncQueue.ts new file mode 100644 index 0000000000..ed0a73c8d5 --- /dev/null +++ b/packages/react-on-rails-pro/tests/AsyncQueue.ts @@ -0,0 +1,59 @@ +import * as EventEmitter from 'node:events'; + +class AsyncQueue { + private eventEmitter = new EventEmitter<{ data: any, end: any }>(); + private buffer: T[] = []; + private isEnded = false; + + enqueue(value: T) { + if (this.isEnded) { + throw new Error("Queue Ended"); + } + + if (this.eventEmitter.listenerCount('data') > 0) { + this.eventEmitter.emit('data', value); + } else { + this.buffer.push(value); + } + } + + end() { + this.isEnded = true; + this.eventEmitter.emit('end'); + } + + dequeue() { + return new Promise((resolve, reject) => { + const bufferValueIfExist = this.buffer.shift(); + if (bufferValueIfExist) { + resolve(bufferValueIfExist); + } else if (this.isEnded) { + reject(new Error("Queue Ended")); + } else { + let teardown = () => {} + const onData = (value: T) => { + resolve(value); + teardown(); + } + + const onEnd = () => { + reject(new Error("Queue Ended")); + teardown(); + } + + this.eventEmitter.on('data', onData); + this.eventEmitter.on('end', onEnd); + teardown = () => { + this.eventEmitter.off('data', onData); + this.eventEmitter.off('end', onEnd); + } + } + }) + } + + toString() { + return "" + } +} + +export default AsyncQueue; diff --git a/packages/react-on-rails-pro/tests/StreamReader.ts b/packages/react-on-rails-pro/tests/StreamReader.ts new file mode 100644 index 0000000000..0bec83b10b --- /dev/null +++ b/packages/react-on-rails-pro/tests/StreamReader.ts @@ -0,0 +1,33 @@ +import { PassThrough, Readable } from 'node:stream'; +import AsyncQueue from './AsyncQueue'; + +class StreamReader { + private asyncQueue: AsyncQueue; + + constructor(pipeableStream: Pick) { + this.asyncQueue = new AsyncQueue(); + const decoder = new TextDecoder(); + + const readableStream = new PassThrough(); + pipeableStream.pipe(readableStream); + + readableStream.on('data', (chunk) => { + const decodedChunk = decoder.decode(chunk); + this.asyncQueue.enqueue(decodedChunk); + }); + + if (readableStream.closed) { + this.asyncQueue.end(); + } else { + readableStream.on('end', () => { + this.asyncQueue.end(); + }); + } + } + + nextChunk() { + return this.asyncQueue.dequeue(); + } +} + +export default StreamReader; diff --git a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx new file mode 100644 index 0000000000..9aae6e027f --- /dev/null +++ b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx @@ -0,0 +1,165 @@ +/** + * @jest-environment node + */ +/// + +import * as React from 'react'; +import { PassThrough, Readable, Transform } from 'node:stream'; +import { text } from 'node:stream/consumers'; +import { Suspense, PropsWithChildren } from 'react'; + +import * as path from 'path'; +import * as mock from 'mock-fs'; + +import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC'; +import AsyncQueue from './AsyncQueue'; +import StreamReader from './StreamReader'; + +const manifestFileDirectory = path.resolve(__dirname, '../src') +const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); + +mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), +}); + +afterAll(() => mock.restore()); + +const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{asyncQueue: AsyncQueue}>) => { + const value = await asyncQueue.dequeue(); + + return ( + <> +

Data: {value}

+ {children} + + ) +} + +const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) => { + return ( +
+

Async Queue

+ Loading Item1

}> + + Loading Item2

}> + + Loading Item3

}> + +
+
+
+
+
+
+ ) +} + +ReactOnRails.register({ AsyncQueueContainer }); + +const renderComponent = (props: Record) => { + return ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'AsyncQueueContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props, + }); +} + +const createParallelRenders = (size: number) => { + const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue()); + const streams = asyncQueues.map((asyncQueue) => { + return renderComponent({ asyncQueue }); + }); + const readers = streams.map(stream => new StreamReader(stream)); + + const enqueue = (value: string) => asyncQueues.forEach(asyncQueues => asyncQueues.enqueue(value)); + + const removeComponentJsonData = (chunk: string) => { + const parsedJson = JSON.parse(chunk); + const html = parsedJson.html as string; + const santizedHtml = html.split('\n').map(chunkLine => { + if (!chunkLine.includes('"stack":')) { + return chunkLine; + } + + const regexMatch = /(^\d+):\{/.exec(chunkLine) + if (!regexMatch) { + return; + } + + const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{')); + const chunkJson = JSON.parse(chunkJsonString); + delete chunkJson.stack; + return `${regexMatch[1]}:${JSON.stringify(chunkJson)}` + }); + + return JSON.stringify({ + ...parsedJson, + html: santizedHtml, + }); + } + + const expectNextChunk = (nextChunk: string) => Promise.all( + readers.map(async (reader) => { + const chunk = await reader.nextChunk(); + expect(removeComponentJsonData(chunk)).toEqual(removeComponentJsonData(nextChunk)); + }) + ); + + const expectEndOfStream = () => Promise.all( + readers.map(reader => expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/)) + ); + + return { enqueue, expectNextChunk, expectEndOfStream }; +} + +test('Renders concurrent rsc streams as single rsc stream', async () => { + expect.assertions(258); + const asyncQueue = new AsyncQueue(); + const stream = renderComponent({ asyncQueue }); + const reader = new StreamReader(stream); + + const chunks: string[] = []; + let chunk = await reader.nextChunk() + chunks.push(chunk); + expect(chunk).toContain("Async Queue"); + expect(chunk).toContain("Loading Item2"); + expect(chunk).not.toContain("Random Value"); + + asyncQueue.enqueue("Random Value1"); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain("Random Value1"); + + asyncQueue.enqueue("Random Value2"); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain("Random Value2"); + + asyncQueue.enqueue("Random Value3"); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain("Random Value3"); + + await expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/); + + const { enqueue, expectNextChunk, expectEndOfStream } = createParallelRenders(50); + + expect(chunks).toHaveLength(4); + await expectNextChunk(chunks[0]!); + enqueue("Random Value1"); + await expectNextChunk(chunks[1]!); + enqueue("Random Value2"); + await expectNextChunk(chunks[2]!); + enqueue("Random Value3"); + await expectNextChunk(chunks[3]!); + await expectEndOfStream(); +}); diff --git a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx new file mode 100644 index 0000000000..a002ab49ff --- /dev/null +++ b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx @@ -0,0 +1,98 @@ +/** + * @jest-environment node + */ + +import * as React from 'react'; +import { Suspense } from 'react'; +import * as mock from 'mock-fs'; +import * as fs from 'fs'; +import * as path from 'path'; +import { pipeline, finished } from 'stream/promises'; +import { text } from 'stream/consumers'; +import { buildServerRenderer } from 'react-on-rails-rsc/server.node'; +import createReactOutput from 'react-on-rails/createReactOutput'; +import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; + +const PromiseWrapper = async ({ promise, name }: { promise: Promise, name: string }) => { + console.log(`[${name}] Before awaitng`); + const value = await promise; + console.log(`[${name}] After awaitng`); + return ( +

Value: {value}

+ ); +} + +const PromiseContainer = ({ name }: { name: string }) => { + const promise = new Promise((resolve) => { + let i = 0; + const intervalId = setInterval(() => { + console.log(`Interval ${i} at [${name}]`); + i += 1; + if (i === 50) { + clearInterval(intervalId); + resolve(`Value of name ${name}`); + } + }, 1); + }); + + return ( +
+

Initial Header

+ Loading Promise

}> + +
+
+ ); +} + +ReactOnRails.register({ PromiseContainer }); + +const manifestFileDirectory = path.resolve(__dirname, '../src') +const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); + +mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), +}); + +afterAll(() => { + mock.restore(); +}) + +test('no logs leakage between concurrent rendering components', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: "First Unique Name" } + }); + const readable2 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: "Second Unique Name" } + }); + + const [content1, content2] = await Promise.all([text(readable1), text(readable2)]); + + expect(content1).toContain("First Unique Name"); + expect(content2).toContain("Second Unique Name"); + // expect(content1.match(/First Unique Name/g)).toHaveLength(55) + // expect(content2.match(/Second Unique Name/g)).toHaveLength(55) + expect(content1).not.toContain("Second Unique Name"); + expect(content2).not.toContain("First Unique Name"); + + // expect(content1.replace(new RegExp("First Unique Name", 'g'), "Second Unique Name")).toEqual(content2); +}) diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index 20891eb2a2..c1fc46518a 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -225,7 +225,6 @@ export interface RenderParams extends Params { export interface RSCRenderParams extends Omit { railsContext: RailsContextWithServerStreamingCapabilities; - reactClientManifestFileName: string; } export interface CreateParams extends Params { diff --git a/yarn.lock b/yarn.lock index 6038b342d6..ee4c29eeb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1654,6 +1654,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^20.17.16": version "20.17.16" resolved "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz" @@ -1662,9 +1669,9 @@ undici-types "~6.19.2" "@types/prop-types@*": - version "15.7.14" - resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== "@types/react-dom@^18.3.5": version "18.3.5" @@ -1672,9 +1679,9 @@ integrity sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q== "@types/react@^18.3.18": - version "18.3.18" - resolved "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz" - integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ== + version "18.3.26" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.26.tgz#4c5970878d30db3d2a0bca1e4eb5f258e391bbeb" + integrity sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -4729,6 +4736,11 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +mock-fs@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.5.0.tgz#94a46d299aaa588e735a201cbe823c876e91f385" + integrity sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA== + mri@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz" From e4ae8b4c9bf87b98e3f63adedd4df53f345bd64a Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Thu, 30 Oct 2025 20:09:57 +0300 Subject: [PATCH 2/5] document bug behavior in React --- ...oncurrentRSCPayloadGeneration.rsc.test.tsx | 28 +------ ...serverRenderRSCReactComponent.rsc.test.tsx | 75 +++++++++++++++++-- .../tests/utils/removeRSCChunkStack.ts | 26 +++++++ .../utils/removeRSCStackFromAllChunks.ts | 9 +++ 4 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts create mode 100644 packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts diff --git a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx index 9aae6e027f..6c59a36abb 100644 --- a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx +++ b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx @@ -14,6 +14,7 @@ import * as mock from 'mock-fs'; import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC'; import AsyncQueue from './AsyncQueue'; import StreamReader from './StreamReader'; +import removeRSCChunkStack from './utils/removeRSCChunkStack'; const manifestFileDirectory = path.resolve(__dirname, '../src') const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); @@ -82,35 +83,10 @@ const createParallelRenders = (size: number) => { const enqueue = (value: string) => asyncQueues.forEach(asyncQueues => asyncQueues.enqueue(value)); - const removeComponentJsonData = (chunk: string) => { - const parsedJson = JSON.parse(chunk); - const html = parsedJson.html as string; - const santizedHtml = html.split('\n').map(chunkLine => { - if (!chunkLine.includes('"stack":')) { - return chunkLine; - } - - const regexMatch = /(^\d+):\{/.exec(chunkLine) - if (!regexMatch) { - return; - } - - const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{')); - const chunkJson = JSON.parse(chunkJsonString); - delete chunkJson.stack; - return `${regexMatch[1]}:${JSON.stringify(chunkJson)}` - }); - - return JSON.stringify({ - ...parsedJson, - html: santizedHtml, - }); - } - const expectNextChunk = (nextChunk: string) => Promise.all( readers.map(async (reader) => { const chunk = await reader.nextChunk(); - expect(removeComponentJsonData(chunk)).toEqual(removeComponentJsonData(nextChunk)); + expect(removeRSCChunkStack(chunk)).toEqual(removeRSCChunkStack(nextChunk)); }) ); diff --git a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx index a002ab49ff..4ed8ef6782 100644 --- a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx +++ b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx @@ -12,6 +12,7 @@ import { text } from 'stream/consumers'; import { buildServerRenderer } from 'react-on-rails-rsc/server.node'; import createReactOutput from 'react-on-rails/createReactOutput'; import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; +import removeRSCStackFromAllChunks from './utils/removeRSCStackFromAllChunks.ts' const PromiseWrapper = async ({ promise, name }: { promise: Promise, name: string }) => { console.log(`[${name}] Before awaitng`); @@ -59,7 +60,7 @@ mock({ afterAll(() => { mock.restore(); -}) +}); test('no logs leakage between concurrent rendering components', async () => { const readable1 = ReactOnRails.serverRenderRSCReactComponent({ @@ -89,10 +90,74 @@ test('no logs leakage between concurrent rendering components', async () => { expect(content1).toContain("First Unique Name"); expect(content2).toContain("Second Unique Name"); - // expect(content1.match(/First Unique Name/g)).toHaveLength(55) - // expect(content2.match(/Second Unique Name/g)).toHaveLength(55) expect(content1).not.toContain("Second Unique Name"); expect(content2).not.toContain("First Unique Name"); +}); + +test('no logs lekage from outside the component', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: "First Unique Name" } + }); - // expect(content1.replace(new RegExp("First Unique Name", 'g'), "Second Unique Name")).toEqual(content2); -}) + const promise = new Promise((resolve) => { + let i = 0; + const intervalId = setInterval(() => { + console.log(`Interval ${i} at [Outside The Component]`); + i += 1; + if (i === 50) { + clearInterval(intervalId); + resolve(); + } + }, 1); + }); + + const [content1] = await Promise.all([text(readable1), promise]); + + expect(content1).toContain("First Unique Name"); + expect(content1).not.toContain("Outside The Component"); +}); + +test('[bug] catches logs outside the component during reading the stream', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: "First Unique Name" } + }); + + let content1 = ""; + let i = 0; + readable1.on('data', (chunk) => { + i += 1; + // To avoid infinite loop + if (i < 5) { + console.log("Outside The Component"); + } + content1 += chunk.toString(); + }); + + // However, any logs from outside the stream 'data' event callback is not catched + const intervalId = setInterval(() => { + console.log("From Interval") + }, 2); + await finished(readable1); + clearInterval(intervalId); + + expect(content1).toContain("First Unique Name"); + expect(content1).not.toContain("From Interval"); + // Here's the bug + expect(content1).toContain("Outside The Component"); +}); diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts new file mode 100644 index 0000000000..94e50e5a7d --- /dev/null +++ b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts @@ -0,0 +1,26 @@ +const removeRSCChunkStack = (chunk: string) => { + const parsedJson = JSON.parse(chunk); + const html = parsedJson.html as string; + const santizedHtml = html.split('\n').map(chunkLine => { + if (!chunkLine.includes('"stack":')) { + return chunkLine; + } + + const regexMatch = /(^\d+):\{/.exec(chunkLine) + if (!regexMatch) { + return; + } + + const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{')); + const chunkJson = JSON.parse(chunkJsonString); + delete chunkJson.stack; + return `${regexMatch[1]}:${JSON.stringify(chunkJson)}` + }); + + return JSON.stringify({ + ...parsedJson, + html: santizedHtml, + }); +} + +export default removeRSCChunkStack; diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts new file mode 100644 index 0000000000..b932f3265b --- /dev/null +++ b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts @@ -0,0 +1,9 @@ +import removeRSCChunkStack from "./removeRSCChunkStack.ts"; + +const removeRSCStackFromAllChunks = (allChunks: string) => { + return allChunks.split('\n') + .map((chunk) => chunk.trim().length > 0 ? removeRSCChunkStack(chunk) : chunk) + .join('\n'); +} + +export default removeRSCStackFromAllChunks; From 8e857ac770c451527aa748ab983a0b9e2ab14251 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Fri, 31 Oct 2025 18:19:23 +0200 Subject: [PATCH 3/5] add concurrency tests for SSRing server component containing client components --- react_on_rails_pro/package.json | 1 + .../tests/concurrentHtmlStreaming.test.ts | 146 +++++++++++++++ .../node-renderer/tests/htmlStreaming.test.js | 54 +----- .../node-renderer/tests/httpRequestUtils.ts | 176 ++++++++++++++++++ react_on_rails_pro/yarn.lock | 76 ++++++++ 5 files changed, 400 insertions(+), 53 deletions(-) create mode 100644 react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts create mode 100644 react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts diff --git a/react_on_rails_pro/package.json b/react_on_rails_pro/package.json index db05cccff1..26cb4705e2 100644 --- a/react_on_rails_pro/package.json +++ b/react_on_rails_pro/package.json @@ -72,6 +72,7 @@ "jest-junit": "^16.0.0", "jsdom": "^16.5.0", "ndb": "^1.1.5", + "node-html-parser": "^7.0.1", "nps": "^5.9.12", "pino-pretty": "^13.0.0", "prettier": "^3.2.5", diff --git a/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts b/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts new file mode 100644 index 0000000000..e3dc5a3500 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'crypto'; +import { createClient } from 'redis'; +import parser from 'node-html-parser'; + +// @ts-expect-error TODO: fix later +import { RSCPayloadChunk } from 'react-on-rails'; +import buildApp from '../src/worker'; +import config from './testingNodeRendererConfigs'; +import { makeRequest } from './httpRequestUtils'; +import { Config } from '../src/shared/configBuilder'; + +const app = buildApp(config as Partial); +const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +const redisClient = createClient({ url: redisUrl }); + +beforeAll(async () => { + await redisClient.connect(); + await app.ready(); + await app.listen({ port: 0 }); +}); + +afterAll(async () => { + await app.close(); + await redisClient.close(); +}); + +const sendRedisValue = async (redisRequestId: string, key: string, value: string) => { + await redisClient.xAdd(`stream:${redisRequestId}`, '*', { [`:${key}`]: JSON.stringify(value) }); +}; + +const sendRedisItemValue = async (redisRequestId: string, itemIndex: number, value: string) => { + await sendRedisValue(redisRequestId, `Item${itemIndex}`, value); +}; + +const extractHtmlFromChunks = (chunks: string) => { + const html = chunks + .split('\n') + .map((chunk) => (chunk.trim().length > 0 ? (JSON.parse(chunk) as RSCPayloadChunk).html : chunk)) + .join(''); + const parsedHtml = parser.parse(html); + // TODO: investigate why ReactOnRails produces different RSC payload on each request + parsedHtml.querySelectorAll('script').forEach((x) => x.remove()); + const sanitizedHtml = parsedHtml.toString(); + return sanitizedHtml; +}; + +const createParallelRenders = (size: number) => { + const redisRequestIds = Array(size) + .fill(null) + .map(() => randomUUID()); + const renderRequests = redisRequestIds.map((redisRequestId) => { + return makeRequest(app, { + componentName: 'RedisReceiver', + props: { requestId: redisRequestId }, + }); + }); + + const expectNextChunk = async (expectedNextChunk: string) => { + const nextChunks = await Promise.all( + renderRequests.map((renderRequest) => renderRequest.waitForNextChunk()), + ); + nextChunks.forEach((chunk, index) => { + const redisRequestId = redisRequestIds[index]!; + const chunksAfterRemovingRequestId = chunk.replace(new RegExp(redisRequestId, 'g'), ''); + expect(extractHtmlFromChunks(chunksAfterRemovingRequestId)).toEqual( + extractHtmlFromChunks(expectedNextChunk), + ); + }); + }; + + const sendRedisItemValues = async (itemIndex: number, itemValue: string) => { + await Promise.all( + redisRequestIds.map((redisRequestId) => sendRedisItemValue(redisRequestId, itemIndex, itemValue)), + ); + }; + + const waitUntilFinished = async () => { + await Promise.all(renderRequests.map((renderRequest) => renderRequest.finishedPromise)); + renderRequests.forEach((renderRequest) => { + expect(renderRequest.getBuffer()).toHaveLength(0); + }); + }; + + return { + expectNextChunk, + sendRedisItemValues, + waitUntilFinished, + }; +}; + +test('Happy Path', async () => { + const parallelInstances = 50; + expect.assertions(parallelInstances * 7 + 7); + const redisRequestId = randomUUID(); + const { waitForNextChunk, finishedPromise, getBuffer } = makeRequest(app, { + componentName: 'RedisReceiver', + props: { requestId: redisRequestId }, + }); + const chunks: string[] = []; + let chunk = await waitForNextChunk(); + expect(chunk).not.toContain('Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 0, 'First Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('First Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 4, 'Fifth Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Fifth Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 2, 'Third Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Third Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 1, 'Second Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Second Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 3, 'Forth Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Forth Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await finishedPromise; + expect(getBuffer).toHaveLength(0); + + const { expectNextChunk, sendRedisItemValues, waitUntilFinished } = + createParallelRenders(parallelInstances); + await expectNextChunk(chunks[0]!); + await sendRedisItemValues(0, 'First Unique Value'); + await expectNextChunk(chunks[1]!); + await sendRedisItemValues(4, 'Fifth Unique Value'); + await expectNextChunk(chunks[2]!); + await sendRedisItemValues(2, 'Third Unique Value'); + await expectNextChunk(chunks[3]!); + await sendRedisItemValues(1, 'Second Unique Value'); + await expectNextChunk(chunks[4]!); + await sendRedisItemValues(3, 'Forth Unique Value'); + await expectNextChunk(chunks[5]!); + await waitUntilFinished(); +}, 50000); diff --git a/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js b/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js index b09046de38..cd59220570 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js +++ b/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js @@ -1,12 +1,8 @@ -import fs from 'fs'; import http2 from 'http2'; -import path from 'path'; -import FormData from 'form-data'; import buildApp from '../src/worker'; import config from './testingNodeRendererConfigs'; -import { readRenderingRequest } from './helper'; import * as errorReporter from '../src/shared/errorReporter'; -import packageJson from '../src/shared/packageJson'; +import { createForm, SERVER_BUNDLE_TIMESTAMP } from './httpRequestUtils'; const app = buildApp(config); @@ -21,54 +17,6 @@ afterAll(async () => { jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn()); -const SERVER_BUNDLE_TIMESTAMP = '77777-test'; -// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture -const RSC_BUNDLE_TIMESTAMP = '88888-test'; - -const createForm = ({ project = 'spec-dummy', commit = '', props = {}, throwJsErrors = false } = {}) => { - const form = new FormData(); - form.append('gemVersion', packageJson.version); - form.append('protocolVersion', packageJson.protocolVersion); - form.append('password', 'myPassword1'); - form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); - - let renderingRequestCode = readRenderingRequest( - project, - commit, - 'asyncComponentsTreeForTestingRenderingRequest.js', - ); - renderingRequestCode = renderingRequestCode.replace(/\(\s*\)\s*$/, `(undefined, ${JSON.stringify(props)})`); - if (throwJsErrors) { - renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); - } - form.append('renderingRequest', renderingRequestCode); - - const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated'); - const testClientBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test'); - const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js'); - form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), { - contentType: 'text/javascript', - filename: 'server-bundle.js', - }); - const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js'); - form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), { - contentType: 'text/javascript', - filename: 'rsc-bundle.js', - }); - const clientManifestPath = path.join(testClientBundlesDirectory, 'react-client-manifest.json'); - form.append('asset1', fs.createReadStream(clientManifestPath), { - contentType: 'application/json', - filename: 'react-client-manifest.json', - }); - const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json'); - form.append('asset2', fs.createReadStream(reactServerClientManifestPath), { - contentType: 'application/json', - filename: 'react-server-client-manifest.json', - }); - - return form; -}; - const makeRequest = async (options = {}) => { const startTime = Date.now(); const form = createForm(options); diff --git a/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts new file mode 100644 index 0000000000..7baab23a2e --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts @@ -0,0 +1,176 @@ +import fs from 'fs'; +import path from 'path'; +import http2 from 'http2'; +import FormData from 'form-data'; +import buildApp from '../src/worker'; +import { readRenderingRequest } from './helper'; +import packageJson from '../src/shared/packageJson'; + +export const SERVER_BUNDLE_TIMESTAMP = '77777-test'; +// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture +export const RSC_BUNDLE_TIMESTAMP = '88888-test'; + +type RequestOptions = { + project: string; + commit: string; + props: Record; + throwJsErrors: boolean; + componentName: string; + renderRscPayload: boolean; +}; + +export const createForm = ({ + project = 'spec-dummy', + commit = '', + props = {}, + throwJsErrors = false, + componentName = undefined, +}: Partial = {}) => { + const form = new FormData(); + form.append('gemVersion', packageJson.version); + form.append('protocolVersion', packageJson.protocolVersion); + form.append('password', 'myPassword1'); + form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); + + let renderingRequestCode = readRenderingRequest( + project, + commit, + 'asyncComponentsTreeForTestingRenderingRequest.js', + ); + const componentNameString = componentName ? `'${componentName}'` : String(undefined); + renderingRequestCode = renderingRequestCode.replace( + /\(\s*\)\s*$/, + `(${componentNameString}, ${JSON.stringify(props)})`, + ); + if (throwJsErrors) { + renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); + } + form.append('renderingRequest', renderingRequestCode); + + const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated'); + const testClientBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test'); + const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js'); + form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), { + contentType: 'text/javascript', + filename: 'server-bundle.js', + }); + const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js'); + form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), { + contentType: 'text/javascript', + filename: 'rsc-bundle.js', + }); + const clientManifestPath = path.join(testClientBundlesDirectory, 'react-client-manifest.json'); + form.append('asset1', fs.createReadStream(clientManifestPath), { + contentType: 'application/json', + filename: 'react-client-manifest.json', + }); + const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json'); + form.append('asset2', fs.createReadStream(reactServerClientManifestPath), { + contentType: 'application/json', + filename: 'react-server-client-manifest.json', + }); + + return form; +}; + +const getAppUrl = (app: ReturnType) => { + const addresssInfo = app.server.address(); + if (!addresssInfo) { + throw new Error('The app has no address, ensure to run the app before running tests'); + } + + if (typeof addresssInfo === 'string') { + return addresssInfo; + } + + return `http://localhost:${addresssInfo.port}`; +}; + +export const makeRequest = (app: ReturnType, options: Partial = {}) => { + const form = createForm(options); + const client = http2.connect(getAppUrl(app)); + const usedBundleTimestamp = options.renderRscPayload ? RSC_BUNDLE_TIMESTAMP : SERVER_BUNDLE_TIMESTAMP; + const request = client.request({ + ':method': 'POST', + ':path': `/bundles/${usedBundleTimestamp}/render/454a82526211afdb215352755d36032c`, + 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, + }); + request.setEncoding('utf8'); + + const buffer: string[] = []; + + const statusPromise = new Promise((resolve) => { + request.on('response', (headers) => { + resolve(headers[':status']); + }); + }); + + let resolveChunksPromise: ((chunks: string) => void) | undefined; + let rejectChunksPromise: ((error: unknown) => void) | undefined; + let resolveChunkPromiseTimeout: NodeJS.Timeout | undefined; + + const scheduleResolveChunkPromise = () => { + if (resolveChunkPromiseTimeout) { + clearTimeout(resolveChunkPromiseTimeout); + } + + resolveChunkPromiseTimeout = setTimeout(() => { + resolveChunksPromise?.(buffer.join('')); + resolveChunksPromise = undefined; + rejectChunksPromise = undefined; + buffer.length = 0; + }, 1000); + }; + + request.on('data', (data: Buffer) => { + buffer.push(data.toString()); + if (resolveChunksPromise) { + scheduleResolveChunkPromise(); + } + }); + + form.pipe(request); + form.on('end', () => { + request.end(); + }); + + const rejectPendingChunkPromise = () => { + if (rejectChunksPromise && buffer.length === 0) { + rejectChunksPromise('Request already eneded'); + } + }; + + const finishedPromise = new Promise((resolve, reject) => { + request.on('end', () => { + client.destroy(); + resolve(); + rejectPendingChunkPromise(); + }); + request.on('error', (err) => { + client.destroy(); + reject(err instanceof Error ? err : new Error(String(err))); + rejectPendingChunkPromise(); + }); + }); + + const waitForNextChunk = () => + new Promise((resolve, reject) => { + if (client.closed && buffer.length === 0) { + reject(new Error('Request already eneded')); + } + resolveChunksPromise = resolve; + rejectChunksPromise = reject; + if (buffer.length > 0) { + scheduleResolveChunkPromise(); + } + }); + + const getBuffer = () => [...buffer]; + + return { + statusPromise, + finishedPromise, + waitForNextChunk, + getBuffer, + }; +}; diff --git a/react_on_rails_pro/yarn.lock b/react_on_rails_pro/yarn.lock index 821d238200..0bbd578072 100644 --- a/react_on_rails_pro/yarn.lock +++ b/react_on_rails_pro/yarn.lock @@ -2377,6 +2377,11 @@ body-parser@^1.20.1, "body-parser@npm:empty-npm-package@1.0.0": resolved "https://registry.yarnpkg.com/empty-npm-package/-/empty-npm-package-1.0.0.tgz#fda29eb6de5efa391f73d578697853af55f6793a" integrity sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA== +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + boxen@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -2810,6 +2815,22 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg== +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -2969,6 +2990,20 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -2976,6 +3011,22 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + dot-prop@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4" @@ -3041,6 +3092,11 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4062,6 +4118,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + help-me@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/help-me/-/help-me-5.0.0.tgz#b1ebe63b967b74060027c2ac61f9be12d354a6f6" @@ -5450,6 +5511,14 @@ ndb@^1.1.5: optionalDependencies: node-pty "^0.9.0-beta18" +node-html-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-7.0.1.tgz#e3056550bae48517ebf161a0b0638f4b0123dfe3" + integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== + dependencies: + css-select "^5.1.0" + he "1.2.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5526,6 +5595,13 @@ nps@^5.9.12: type-detect "^4.0.3" yargs "14.2.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + nwsapi@^2.2.0: version "2.2.10" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" From ec23294e30ee6e52a6c999b428a8a3b6f6f2b460 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 1 Nov 2025 19:10:17 +0200 Subject: [PATCH 4/5] fix tests caused by mock-fs and linting --- .../react-on-rails-pro/tests/AsyncQueue.ts | 26 +++-- .../react-on-rails-pro/tests/StreamReader.ts | 4 +- ...oncurrentRSCPayloadGeneration.rsc.test.tsx | 101 +++++++++--------- ...serverRenderRSCReactComponent.rsc.test.tsx | 69 ++++++------ .../tests/utils/removeRSCChunkStack.ts | 18 ++-- .../utils/removeRSCStackFromAllChunks.ts | 11 +- 6 files changed, 115 insertions(+), 114 deletions(-) diff --git a/packages/react-on-rails-pro/tests/AsyncQueue.ts b/packages/react-on-rails-pro/tests/AsyncQueue.ts index ed0a73c8d5..d6f8b7b204 100644 --- a/packages/react-on-rails-pro/tests/AsyncQueue.ts +++ b/packages/react-on-rails-pro/tests/AsyncQueue.ts @@ -1,13 +1,15 @@ import * as EventEmitter from 'node:events'; class AsyncQueue { - private eventEmitter = new EventEmitter<{ data: any, end: any }>(); + private eventEmitter = new EventEmitter(); + private buffer: T[] = []; + private isEnded = false; enqueue(value: T) { if (this.isEnded) { - throw new Error("Queue Ended"); + throw new Error('Queue Ended'); } if (this.eventEmitter.listenerCount('data') > 0) { @@ -28,31 +30,27 @@ class AsyncQueue { if (bufferValueIfExist) { resolve(bufferValueIfExist); } else if (this.isEnded) { - reject(new Error("Queue Ended")); + reject(new Error('Queue Ended')); } else { - let teardown = () => {} + let teardown = () => {}; const onData = (value: T) => { resolve(value); teardown(); - } - + }; + const onEnd = () => { - reject(new Error("Queue Ended")); + reject(new Error('Queue Ended')); teardown(); - } + }; this.eventEmitter.on('data', onData); this.eventEmitter.on('end', onEnd); teardown = () => { this.eventEmitter.off('data', onData); this.eventEmitter.off('end', onEnd); - } + }; } - }) - } - - toString() { - return "" + }); } } diff --git a/packages/react-on-rails-pro/tests/StreamReader.ts b/packages/react-on-rails-pro/tests/StreamReader.ts index 0bec83b10b..86ee5195b4 100644 --- a/packages/react-on-rails-pro/tests/StreamReader.ts +++ b/packages/react-on-rails-pro/tests/StreamReader.ts @@ -1,5 +1,5 @@ import { PassThrough, Readable } from 'node:stream'; -import AsyncQueue from './AsyncQueue'; +import AsyncQueue from './AsyncQueue.ts'; class StreamReader { private asyncQueue: AsyncQueue; @@ -11,7 +11,7 @@ class StreamReader { const readableStream = new PassThrough(); pipeableStream.pipe(readableStream); - readableStream.on('data', (chunk) => { + readableStream.on('data', (chunk: Buffer) => { const decodedChunk = decoder.decode(chunk); this.asyncQueue.enqueue(decodedChunk); }); diff --git a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx index 6c59a36abb..353b19d6f8 100644 --- a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx +++ b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx @@ -4,31 +4,34 @@ /// import * as React from 'react'; -import { PassThrough, Readable, Transform } from 'node:stream'; -import { text } from 'node:stream/consumers'; import { Suspense, PropsWithChildren } from 'react'; import * as path from 'path'; import * as mock from 'mock-fs'; -import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC'; -import AsyncQueue from './AsyncQueue'; -import StreamReader from './StreamReader'; -import removeRSCChunkStack from './utils/removeRSCChunkStack'; +import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; +import AsyncQueue from './AsyncQueue.ts'; +import StreamReader from './StreamReader.ts'; +import removeRSCChunkStack from './utils/removeRSCChunkStack.ts'; -const manifestFileDirectory = path.resolve(__dirname, '../src') +const manifestFileDirectory = path.resolve(__dirname, '../src'); const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); -mock({ - [clientManifestPath]: JSON.stringify({ - filePathToModuleMetadata: {}, - moduleLoading: { prefix: '', crossOrigin: null }, - }), +beforeEach(() => { + mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), + }); }); -afterAll(() => mock.restore()); +afterEach(() => mock.restore()); -const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{asyncQueue: AsyncQueue}>) => { +const AsyncQueueItem = async ({ + asyncQueue, + children, +}: PropsWithChildren<{ asyncQueue: AsyncQueue }>) => { const value = await asyncQueue.dequeue(); return ( @@ -36,8 +39,8 @@ const AsyncQueueItem = async ({ asyncQueue, children }: PropsWithChildren<{asyn

Data: {value}

{children} - ) -} + ); +}; const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) => { return ( @@ -55,8 +58,8 @@ const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) - ) -} + ); +}; ReactOnRails.register({ AsyncQueueContainer }); @@ -72,30 +75,30 @@ const renderComponent = (props: Record) => { domNodeId: 'dom-id', props, }); -} +}; const createParallelRenders = (size: number) => { const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue()); const streams = asyncQueues.map((asyncQueue) => { return renderComponent({ asyncQueue }); }); - const readers = streams.map(stream => new StreamReader(stream)); + const readers = streams.map((stream) => new StreamReader(stream)); - const enqueue = (value: string) => asyncQueues.forEach(asyncQueues => asyncQueues.enqueue(value)); + const enqueue = (value: string) => asyncQueues.forEach((asyncQueue) => asyncQueue.enqueue(value)); - const expectNextChunk = (nextChunk: string) => Promise.all( - readers.map(async (reader) => { - const chunk = await reader.nextChunk(); - expect(removeRSCChunkStack(chunk)).toEqual(removeRSCChunkStack(nextChunk)); - }) - ); - - const expectEndOfStream = () => Promise.all( - readers.map(reader => expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/)) - ); + const expectNextChunk = (nextChunk: string) => + Promise.all( + readers.map(async (reader) => { + const chunk = await reader.nextChunk(); + expect(removeRSCChunkStack(chunk)).toEqual(removeRSCChunkStack(nextChunk)); + }), + ); + + const expectEndOfStream = () => + Promise.all(readers.map((reader) => expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/))); return { enqueue, expectNextChunk, expectEndOfStream }; -} +}; test('Renders concurrent rsc streams as single rsc stream', async () => { expect.assertions(258); @@ -104,38 +107,38 @@ test('Renders concurrent rsc streams as single rsc stream', async () => { const reader = new StreamReader(stream); const chunks: string[] = []; - let chunk = await reader.nextChunk() + let chunk = await reader.nextChunk(); chunks.push(chunk); - expect(chunk).toContain("Async Queue"); - expect(chunk).toContain("Loading Item2"); - expect(chunk).not.toContain("Random Value"); + expect(chunk).toContain('Async Queue'); + expect(chunk).toContain('Loading Item2'); + expect(chunk).not.toContain('Random Value'); - asyncQueue.enqueue("Random Value1"); + asyncQueue.enqueue('Random Value1'); chunk = await reader.nextChunk(); chunks.push(chunk); - expect(chunk).toContain("Random Value1"); + expect(chunk).toContain('Random Value1'); - asyncQueue.enqueue("Random Value2"); + asyncQueue.enqueue('Random Value2'); chunk = await reader.nextChunk(); chunks.push(chunk); - expect(chunk).toContain("Random Value2"); + expect(chunk).toContain('Random Value2'); - asyncQueue.enqueue("Random Value3"); + asyncQueue.enqueue('Random Value3'); chunk = await reader.nextChunk(); chunks.push(chunk); - expect(chunk).toContain("Random Value3"); + expect(chunk).toContain('Random Value3'); await expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/); const { enqueue, expectNextChunk, expectEndOfStream } = createParallelRenders(50); expect(chunks).toHaveLength(4); - await expectNextChunk(chunks[0]!); - enqueue("Random Value1"); - await expectNextChunk(chunks[1]!); - enqueue("Random Value2"); - await expectNextChunk(chunks[2]!); - enqueue("Random Value3"); - await expectNextChunk(chunks[3]!); + await expectNextChunk(chunks[0]); + enqueue('Random Value1'); + await expectNextChunk(chunks[1]); + enqueue('Random Value2'); + await expectNextChunk(chunks[2]); + enqueue('Random Value3'); + await expectNextChunk(chunks[3]); await expectEndOfStream(); }); diff --git a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx index 4ed8ef6782..4053f8280e 100644 --- a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx +++ b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx @@ -1,27 +1,22 @@ /** * @jest-environment node */ +/// import * as React from 'react'; import { Suspense } from 'react'; import * as mock from 'mock-fs'; -import * as fs from 'fs'; import * as path from 'path'; -import { pipeline, finished } from 'stream/promises'; +import { finished } from 'stream/promises'; import { text } from 'stream/consumers'; -import { buildServerRenderer } from 'react-on-rails-rsc/server.node'; -import createReactOutput from 'react-on-rails/createReactOutput'; import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; -import removeRSCStackFromAllChunks from './utils/removeRSCStackFromAllChunks.ts' -const PromiseWrapper = async ({ promise, name }: { promise: Promise, name: string }) => { +const PromiseWrapper = async ({ promise, name }: { promise: Promise; name: string }) => { console.log(`[${name}] Before awaitng`); const value = await promise; console.log(`[${name}] After awaitng`); - return ( -

Value: {value}

- ); -} + return

Value: {value}

; +}; const PromiseContainer = ({ name }: { name: string }) => { const promise = new Promise((resolve) => { @@ -44,21 +39,23 @@ const PromiseContainer = ({ name }: { name: string }) => { ); -} +}; ReactOnRails.register({ PromiseContainer }); -const manifestFileDirectory = path.resolve(__dirname, '../src') +const manifestFileDirectory = path.resolve(__dirname, '../src'); const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); -mock({ - [clientManifestPath]: JSON.stringify({ - filePathToModuleMetadata: {}, - moduleLoading: { prefix: '', crossOrigin: null }, - }), +beforeEach(() => { + mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), + }); }); -afterAll(() => { +afterEach(() => { mock.restore(); }); @@ -72,7 +69,7 @@ test('no logs leakage between concurrent rendering components', async () => { renderingReturnsPromises: true, throwJsErrors: true, domNodeId: 'dom-id', - props: { name: "First Unique Name" } + props: { name: 'First Unique Name' }, }); const readable2 = ReactOnRails.serverRenderRSCReactComponent({ railsContext: { @@ -83,15 +80,15 @@ test('no logs leakage between concurrent rendering components', async () => { renderingReturnsPromises: true, throwJsErrors: true, domNodeId: 'dom-id', - props: { name: "Second Unique Name" } + props: { name: 'Second Unique Name' }, }); const [content1, content2] = await Promise.all([text(readable1), text(readable2)]); - expect(content1).toContain("First Unique Name"); - expect(content2).toContain("Second Unique Name"); - expect(content1).not.toContain("Second Unique Name"); - expect(content2).not.toContain("First Unique Name"); + expect(content1).toContain('First Unique Name'); + expect(content2).toContain('Second Unique Name'); + expect(content1).not.toContain('Second Unique Name'); + expect(content2).not.toContain('First Unique Name'); }); test('no logs lekage from outside the component', async () => { @@ -104,7 +101,7 @@ test('no logs lekage from outside the component', async () => { renderingReturnsPromises: true, throwJsErrors: true, domNodeId: 'dom-id', - props: { name: "First Unique Name" } + props: { name: 'First Unique Name' }, }); const promise = new Promise((resolve) => { @@ -121,8 +118,8 @@ test('no logs lekage from outside the component', async () => { const [content1] = await Promise.all([text(readable1), promise]); - expect(content1).toContain("First Unique Name"); - expect(content1).not.toContain("Outside The Component"); + expect(content1).toContain('First Unique Name'); + expect(content1).not.toContain('Outside The Component'); }); test('[bug] catches logs outside the component during reading the stream', async () => { @@ -135,29 +132,29 @@ test('[bug] catches logs outside the component during reading the stream', async renderingReturnsPromises: true, throwJsErrors: true, domNodeId: 'dom-id', - props: { name: "First Unique Name" } + props: { name: 'First Unique Name' }, }); - - let content1 = ""; + + let content1 = ''; let i = 0; - readable1.on('data', (chunk) => { + readable1.on('data', (chunk: Buffer) => { i += 1; // To avoid infinite loop if (i < 5) { - console.log("Outside The Component"); + console.log('Outside The Component'); } content1 += chunk.toString(); }); // However, any logs from outside the stream 'data' event callback is not catched const intervalId = setInterval(() => { - console.log("From Interval") + console.log('From Interval'); }, 2); await finished(readable1); clearInterval(intervalId); - expect(content1).toContain("First Unique Name"); - expect(content1).not.toContain("From Interval"); + expect(content1).toContain('First Unique Name'); + expect(content1).not.toContain('From Interval'); // Here's the bug - expect(content1).toContain("Outside The Component"); + expect(content1).toContain('Outside The Component'); }); diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts index 94e50e5a7d..4774feadfa 100644 --- a/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts +++ b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts @@ -1,26 +1,28 @@ +import { RSCPayloadChunk } from 'react-on-rails'; + const removeRSCChunkStack = (chunk: string) => { - const parsedJson = JSON.parse(chunk); - const html = parsedJson.html as string; - const santizedHtml = html.split('\n').map(chunkLine => { + const parsedJson = JSON.parse(chunk) as RSCPayloadChunk; + const { html } = parsedJson; + const santizedHtml = html.split('\n').map((chunkLine) => { if (!chunkLine.includes('"stack":')) { return chunkLine; } - const regexMatch = /(^\d+):\{/.exec(chunkLine) + const regexMatch = /(^\d+):\{/.exec(chunkLine); if (!regexMatch) { - return; + return chunkLine; } const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{')); - const chunkJson = JSON.parse(chunkJsonString); + const chunkJson = JSON.parse(chunkJsonString) as { stack?: string }; delete chunkJson.stack; - return `${regexMatch[1]}:${JSON.stringify(chunkJson)}` + return `${regexMatch[1]}:${JSON.stringify(chunkJson)}`; }); return JSON.stringify({ ...parsedJson, html: santizedHtml, }); -} +}; export default removeRSCChunkStack; diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts index b932f3265b..717a10aee8 100644 --- a/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts +++ b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts @@ -1,9 +1,10 @@ -import removeRSCChunkStack from "./removeRSCChunkStack.ts"; +import removeRSCChunkStack from './removeRSCChunkStack.ts'; const removeRSCStackFromAllChunks = (allChunks: string) => { - return allChunks.split('\n') - .map((chunk) => chunk.trim().length > 0 ? removeRSCChunkStack(chunk) : chunk) - .join('\n'); -} + return allChunks + .split('\n') + .map((chunk) => (chunk.trim().length > 0 ? removeRSCChunkStack(chunk) : chunk)) + .join('\n'); +}; export default removeRSCStackFromAllChunks; From aa3d0c21891c73dff68f27dd54d7f691aef24ff3 Mon Sep 17 00:00:00 2001 From: Abanoub Ghadban Date: Sat, 1 Nov 2025 20:23:11 +0200 Subject: [PATCH 5/5] ignore "tests/utils/removeRSCStackFromAllChunks.ts" file at knip.ts --- knip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.ts b/knip.ts index 29af90d608..12801f717a 100644 --- a/knip.ts +++ b/knip.ts @@ -75,6 +75,7 @@ const config: KnipConfig = { 'tests/emptyForTesting.js', // Jest setup and test utilities - not detected by Jest plugin in workspace setup 'tests/jest.setup.js', + 'tests/utils/removeRSCStackFromAllChunks.ts', // Build output directories that should be ignored 'lib/**', // Pro features exported for external consumption