diff --git a/packages/react-on-rails-pro/src/AsyncPropsManager.ts b/packages/react-on-rails-pro/src/AsyncPropsManager.ts new file mode 100644 index 0000000000..1ad8156625 --- /dev/null +++ b/packages/react-on-rails-pro/src/AsyncPropsManager.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Shakacode LLC + * + * This file is NOT licensed under the MIT (open source) license. + * It is part of the React on Rails Pro offering and is licensed separately. + * + * Unauthorized copying, modification, distribution, or use of this file, + * via any medium, is strictly prohibited without a valid license agreement + * from Shakacode LLC. + * + * For licensing terms, please see: + * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md + */ + +type PromiseController = { + promise: Promise; + resolve: (propValue: unknown) => void; + reject: (reason: unknown) => void; + resolved: boolean; +}; + +class AsyncPropsManager { + private isClosed: boolean = false; + + private propNameToPromiseController = new Map(); + + // The function is not converted to an async function to ensure that: + // The function returns the same promise on successful scenario, so it can be used inside async react component + // Or with the `use` hook without causing an infinite loop or flicks during rendering + getProp(propName: string) { + const promiseController = this.getOrCreatePromiseController(propName); + if (!promiseController) { + return Promise.reject(AsyncPropsManager.getNoPropFoundError(propName)); + } + + return promiseController.promise; + } + + setProp(propName: string, propValue: unknown) { + const promiseController = this.getOrCreatePromiseController(propName); + if (!promiseController) { + throw new Error(`Can't set the async prop "${propName}" because the stream is already closed`); + } + + promiseController.resolve(propValue); + } + + endStream() { + if (this.isClosed) { + return; + } + + this.isClosed = true; + this.propNameToPromiseController.forEach((promiseController, propName) => { + if (!promiseController.resolved) { + promiseController.reject(AsyncPropsManager.getNoPropFoundError(propName)); + } + }); + } + + private getOrCreatePromiseController(propName: string) { + const promiseController = this.propNameToPromiseController.get(propName); + if (promiseController) { + return promiseController; + } + + if (this.isClosed) { + return undefined; + } + + const partialPromiseController = { + resolved: false, + }; + + let resolvePromise: PromiseController['resolve'] = () => {}; + let rejectPromise: PromiseController['reject'] = () => {}; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const newPromiseController = Object.assign(partialPromiseController, { + promise, + resolve: resolvePromise, + reject: rejectPromise, + }); + this.propNameToPromiseController.set(propName, newPromiseController); + return newPromiseController; + } + + private static getNoPropFoundError(propName: string) { + return new Error( + `The async prop "${propName}" is not received. Esnure to send the async prop from ruby side`, + ); + } +} + +export default AsyncPropsManager; diff --git a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts index a233c156e9..cc78efd588 100644 --- a/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts +++ b/packages/react-on-rails-pro/src/ReactOnRailsRSC.ts @@ -25,6 +25,7 @@ import { import { convertToError } from 'react-on-rails/serverRenderUtils'; import handleError from './handleErrorRSC.ts'; import ReactOnRails from './ReactOnRails.full.ts'; +import AsyncPropsManager from './AsyncPropsManager.ts'; import { streamServerRenderedComponent, @@ -104,6 +105,26 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => { } }; +function addAsyncPropsCapabilityToComponentProps< + AsyncPropsType extends Record, + PropsType extends Record, +>(props: PropsType) { + const asyncPropManager = new AsyncPropsManager(); + const propsAfterAddingAsyncProps = { + ...props, + getReactOnRailsAsyncProp: (propName: PropName) => { + return asyncPropManager.getProp(propName as string) as Promise; + }, + }; + + return { + asyncPropManager, + props: propsAfterAddingAsyncProps, + }; +} + +ReactOnRails.addAsyncPropsCapabilityToComponentProps = addAsyncPropsCapabilityToComponentProps; + ReactOnRails.isRSCBundle = true; export * from 'react-on-rails/types'; diff --git a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts index 28fe296411..bf2aed727e 100644 --- a/packages/react-on-rails-pro/src/createReactOnRailsPro.ts +++ b/packages/react-on-rails-pro/src/createReactOnRailsPro.ts @@ -45,6 +45,7 @@ type ReactOnRailsProSpecificFunctions = Pick< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; // Pro client startup with immediate hydration support @@ -133,6 +134,10 @@ export default function createReactOnRailsPro( serverRenderRSCReactComponent(): any { throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only'); }, + + addAsyncPropsCapabilityToComponentProps() { + throw new Error('addAsyncPropsCapabilityToComponentProps is supported in RSC bundle only'); + }, }; // Type assertion is safe here because: @@ -153,6 +158,11 @@ export default function createReactOnRailsPro( reactOnRailsPro.serverRenderRSCReactComponent; } + if (reactOnRailsPro.addAsyncPropsCapabilityToComponentProps) { + reactOnRailsProSpecificFunctions.addAsyncPropsCapabilityToComponentProps = + reactOnRailsPro.addAsyncPropsCapabilityToComponentProps; + } + // Assign Pro-specific functions to the ReactOnRailsPro object using Object.assign // This pattern ensures we add exactly what's defined in the type, nothing more, nothing less Object.assign(reactOnRailsPro, reactOnRailsProSpecificFunctions); diff --git a/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts b/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts new file mode 100644 index 0000000000..c981bc626f --- /dev/null +++ b/packages/react-on-rails-pro/tests/AsyncPropManager.test.ts @@ -0,0 +1,142 @@ +import AsyncPropsManager from '../src/AsyncPropsManager.ts'; + +describe('Access AsyncPropManager prop before setting it', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + getPropPromise = manager.getProp('randomProp'); + manager.setProp('randomProp', 'Fake Value'); + }); + + it('returns the same value', async () => { + await expect(getPropPromise).resolves.toBe('Fake Value'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('randomProp'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Fake Value'); + }); + + it('allows accessing multiple props', async () => { + const getSecondPropPromise = manager.getProp('secondRandomProp'); + await expect(getPropPromise).resolves.toBe('Fake Value'); + manager.setProp('secondRandomProp', 'Another Fake Value'); + await expect(getSecondPropPromise).resolves.toBe('Another Fake Value'); + }); +}); + +describe('Access AsyncPropManager prop after setting it', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + manager.setProp('randomProp', 'Value got after setting'); + getPropPromise = manager.getProp('randomProp'); + }); + + it('can set the prop before getting it', async () => { + await expect(getPropPromise).resolves.toBe('Value got after setting'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('randomProp'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Value got after setting'); + }); + + it('allows accessing multiple props', async () => { + manager.setProp('secondRandomProp', 'Another Fake Value'); + const getSecondPropPromise = manager.getProp('secondRandomProp'); + await expect(getPropPromise).resolves.toBe('Value got after setting'); + await expect(getSecondPropPromise).resolves.toBe('Another Fake Value'); + }); +}); + +describe('Access AsyncPropManager prop after closing the stream', () => { + let manager: AsyncPropsManager; + let getPropPromise: Promise; + + beforeEach(() => { + manager = new AsyncPropsManager(); + manager.setProp('prop accessed after closing', 'Value got after closing the stream'); + manager.endStream(); + getPropPromise = manager.getProp('prop accessed after closing'); + }); + + it('can set the prop before getting it', async () => { + await expect(getPropPromise).resolves.toBe('Value got after closing the stream'); + }); + + it('returns the same promise on success scenarios', async () => { + const secondGetPropPromise = manager.getProp('prop accessed after closing'); + expect(secondGetPropPromise).toBe(getPropPromise); + await expect(getPropPromise).resolves.toBe('Value got after closing the stream'); + }); +}); + +describe('Access non sent AsyncPropManager prop', () => { + it('throws an error if non-existing prop is sent after closing the stream', async () => { + const manager = new AsyncPropsManager(); + manager.endStream(); + await expect(manager.getProp('Non Existing Prop')).rejects.toThrow( + /The async prop "Non Existing Prop" is not received/, + ); + }); + + it('rejects getPropPromise if the stream is closed before getting the prop value', async () => { + const manager = new AsyncPropsManager(); + const getPropPromise = manager.getProp('wrongProp'); + manager.endStream(); + await expect(getPropPromise).rejects.toThrow(/The async prop "wrongProp" is not received/); + }); + + it('throws an error if a prop is set after closing the stream', () => { + const manager = new AsyncPropsManager(); + manager.endStream(); + expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow( + /Can't set the async prop "wrongProp" because the stream is already closed/, + ); + }); +}); + +describe('Accessing AsyncPropManager prop in complex scenarios', () => { + it('accepts multiple received props and reject multiple non sent props', async () => { + const manager = new AsyncPropsManager(); + const accessBeforeSetPromise = manager.getProp('accessBeforeSetProp'); + const secondAccessBeforeSetPromise = manager.getProp('secondAccessBeforeSetProp'); + const nonExistingPropPromise = manager.getProp('nonExistingProp'); + + // Setting and getting props + manager.setProp('setBeforeAccessProp', 'Set Before Access Prop Value'); + manager.setProp('accessBeforeSetProp', 'Access Before Set Prop Value'); + await expect(accessBeforeSetPromise).resolves.toBe('Access Before Set Prop Value'); + await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value'); + + // Setting another prop + manager.setProp('secondAccessBeforeSetProp', 'Second Access Before Set Prop Value'); + await expect(secondAccessBeforeSetPromise).resolves.toBe('Second Access Before Set Prop Value'); + + // Ensure all props return the same promise + expect(manager.getProp('accessBeforeSetProp')).toBe(manager.getProp('accessBeforeSetProp')); + expect(manager.getProp('secondAccessBeforeSetProp')).toBe(manager.getProp('secondAccessBeforeSetProp')); + expect(manager.getProp('setBeforeAccessProp')).toBe(manager.getProp('setBeforeAccessProp')); + + // Access props one more time + await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value'); + await expect(manager.getProp('accessBeforeSetProp')).resolves.toBe('Access Before Set Prop Value'); + + // Non existing props + manager.endStream(); + await expect(nonExistingPropPromise).rejects.toThrow(/The async prop "nonExistingProp" is not received/); + await expect(manager.getProp('wrongProp')).rejects.toThrow(/The async prop "wrongProp" is not received/); + + // Setting after closing + expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow( + /Can't set the async prop "wrongProp" because the stream is already closed/, + ); + }); +}); diff --git a/packages/react-on-rails-pro/tests/testUtils.js b/packages/react-on-rails-pro/tests/testUtils.ts similarity index 86% rename from packages/react-on-rails-pro/tests/testUtils.js rename to packages/react-on-rails-pro/tests/testUtils.ts index 617259ce13..835e257ea8 100644 --- a/packages/react-on-rails-pro/tests/testUtils.js +++ b/packages/react-on-rails-pro/tests/testUtils.ts @@ -9,8 +9,8 @@ import { Readable } from 'stream'; * }} Object containing the stream and push function */ export const createNodeReadableStream = () => { - const pendingChunks = []; - let pushFn; + const pendingChunks: unknown[] = []; + let pushFn: (chunk: unknown) => void; const stream = new Readable({ read() { pushFn = this.push.bind(this); @@ -20,7 +20,7 @@ export const createNodeReadableStream = () => { }, }); - const push = (chunk) => { + const push = (chunk: unknown) => { if (pushFn) { pushFn(chunk); } else { diff --git a/packages/react-on-rails/src/base/client.ts b/packages/react-on-rails/src/base/client.ts index 44a5b2e954..32beac6485 100644 --- a/packages/react-on-rails/src/base/client.ts +++ b/packages/react-on-rails/src/base/client.ts @@ -51,6 +51,7 @@ export type BaseClientObjectType = Omit< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; // Cache to track created objects and their registries diff --git a/packages/react-on-rails/src/createReactOnRails.ts b/packages/react-on-rails/src/createReactOnRails.ts index e422a0c0af..8bc16d757d 100644 --- a/packages/react-on-rails/src/createReactOnRails.ts +++ b/packages/react-on-rails/src/createReactOnRails.ts @@ -22,6 +22,7 @@ type ReactOnRailsCoreSpecificFunctions = Pick< | 'reactOnRailsStoreLoaded' | 'streamServerRenderedReactComponent' | 'serverRenderRSCReactComponent' + | 'addAsyncPropsCapabilityToComponentProps' >; export default function createReactOnRails( @@ -76,6 +77,10 @@ export default function createReactOnRails( serverRenderRSCReactComponent(): any { throw new Error('serverRenderRSCReactComponent requires react-on-rails-pro package'); }, + + addAsyncPropsCapabilityToComponentProps() { + throw new Error('addAsyncPropsCapabilityToComponentProps requires react-on-rails-pro package'); + }, }; // Type assertion is safe here because: diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index f2553a1499..c58fc811ae 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -139,6 +139,12 @@ type RenderFunctionResult = RenderFunctionSyncResult | RenderFunctionAsyncResult type StreamableComponentResult = ReactElement | Promise; +type AsyncPropsManager = { + getProp: (propName: string) => Promise; + setProp: (propName: string, propValue: unknown) => void; + endStream: () => void; +}; + /** * Render-functions are used to create dynamic React components or server-rendered HTML with side effects. * They receive two arguments: props and railsContext. @@ -355,6 +361,15 @@ export type RSCPayloadStreamInfo = { export type RSCPayloadCallback = (streamInfo: RSCPayloadStreamInfo) => void; +export type WithAsyncProps< + AsyncPropsType extends Record, + PropsType extends Record, +> = PropsType & { + getReactOnRailsAsyncProp: ( + propName: PropName, + ) => Promise; +}; + /** Contains the parts of the `ReactOnRails` API intended for internal use only. */ export interface ReactOnRailsInternal extends ReactOnRails { /** @@ -463,6 +478,19 @@ export interface ReactOnRailsInternal extends ReactOnRails { * Indicates if the RSC bundle is being used. */ isRSCBundle: boolean; + /** + * Adds the getAsyncProp function to the component props object + * @returns An object containitng: the AsyncPropsManager and the component props after adding the getAsyncProp to it + */ + addAsyncPropsCapabilityToComponentProps: < + AsyncPropsType extends Record, + PropsType extends Record, + >( + props: PropsType, + ) => { + asyncPropManager: AsyncPropsManager; + props: WithAsyncProps; + }; } export type RenderStateHtml = FinalHtmlResult | Promise; diff --git a/react_on_rails_pro/packages/node-renderer/src/worker.ts b/react_on_rails_pro/packages/node-renderer/src/worker.ts index 5251d8a863..2557a045ad 100644 --- a/react_on_rails_pro/packages/node-renderer/src/worker.ts +++ b/react_on_rails_pro/packages/node-renderer/src/worker.ts @@ -287,7 +287,7 @@ export default function run(config: Partial) { ); const initial: IncrementalRenderInitialRequest = { - renderingRequest: String((tempReqBody as { renderingRequest?: string }).renderingRequest ?? ''), + firstRequestChunk: obj, bundleTimestamp, dependencyBundleTimestamps, }; @@ -322,6 +322,7 @@ export default function run(config: Partial) { } try { + log.info(`Received a new update chunk ${JSON.stringify(obj)}`); incrementalSink.add(obj); } catch (err) { // Log error but don't stop processing @@ -334,7 +335,11 @@ export default function run(config: Partial) { }, onRequestEnded: () => { - // Do nothing + if (!incrementalSink) { + return; + } + + incrementalSink.handleRequestClosed(); }, }); } catch (err) { diff --git a/react_on_rails_pro/packages/node-renderer/src/worker/handleIncrementalRenderRequest.ts b/react_on_rails_pro/packages/node-renderer/src/worker/handleIncrementalRenderRequest.ts index c15f85fbff..40b0b5515a 100644 --- a/react_on_rails_pro/packages/node-renderer/src/worker/handleIncrementalRenderRequest.ts +++ b/react_on_rails_pro/packages/node-renderer/src/worker/handleIncrementalRenderRequest.ts @@ -6,6 +6,7 @@ import { getRequestBundleFilePath } from '../shared/utils'; export type IncrementalRenderSink = { /** Called for every subsequent NDJSON object after the first one */ add: (chunk: unknown) => void; + handleRequestClosed: () => void; }; export type UpdateChunk = { @@ -27,11 +28,33 @@ function assertIsUpdateChunk(value: unknown): asserts value is UpdateChunk { } export type IncrementalRenderInitialRequest = { - renderingRequest: string; + firstRequestChunk: unknown; bundleTimestamp: string | number; dependencyBundleTimestamps?: string[] | number[]; }; +export type FirstIncrementalRenderRequestChunk = { + renderingRequest: string; + onRequestClosedUpdateChunk?: string; +}; + +function assertFirstIncrementalRenderRequestChunk( + chunk: unknown, +): asserts chunk is FirstIncrementalRenderRequestChunk { + if ( + typeof chunk !== 'object' || + chunk === null || + !('renderingRequest' in chunk) || + typeof chunk.renderingRequest !== 'string' || + // onRequestClosedUpdateChunk is an optional field + ('onRequestClosedUpdateChunk' in chunk && + chunk.onRequestClosedUpdateChunk && + typeof chunk.onRequestClosedUpdateChunk !== 'object') + ) { + throw new Error('Invalid first incremental render request chunk received, missing properties'); + } +} + export type IncrementalRenderResult = { response: ResponseResult; sink?: IncrementalRenderSink; @@ -46,7 +69,9 @@ export type IncrementalRenderResult = { export async function handleIncrementalRenderRequest( initial: IncrementalRenderInitialRequest, ): Promise { - const { renderingRequest, bundleTimestamp, dependencyBundleTimestamps } = initial; + const { firstRequestChunk, bundleTimestamp, dependencyBundleTimestamps } = initial; + assertFirstIncrementalRenderRequestChunk(firstRequestChunk); + const { renderingRequest, onRequestClosedUpdateChunk } = firstRequestChunk; try { // Call handleRenderRequest internally to handle all validation and VM execution @@ -79,6 +104,27 @@ export async function handleIncrementalRenderRequest( log.error({ msg: 'Invalid incremental render chunk', err, chunk }); } }, + handleRequestClosed: () => { + if (!onRequestClosedUpdateChunk) { + return; + } + + try { + assertIsUpdateChunk(onRequestClosedUpdateChunk); + const bundlePath = getRequestBundleFilePath(onRequestClosedUpdateChunk.bundleTimestamp); + executionContext + .runInVM(onRequestClosedUpdateChunk.updateChunk, bundlePath) + .catch((err: unknown) => { + log.error({ + msg: 'Error running onRequestClosedUpdateChunk', + err, + onRequestClosedUpdateChunk, + }); + }); + } catch (err) { + log.error({ msg: 'Invalid onRequestClosedUpdateChunk', err, onRequestClosedUpdateChunk }); + } + }, }, }; } catch (error) { 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 index e3dc5a3500..b4795815c7 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts +++ b/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts @@ -7,9 +7,8 @@ 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 app = buildApp(config); const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; const redisClient = createClient({ url: redisUrl }); diff --git a/react_on_rails_pro/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js b/react_on_rails_pro/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js index 8b48f9bb3f..93417a927a 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js +++ b/react_on_rails_pro/packages/node-renderer/tests/fixtures/projects/spec-dummy/asyncComponentsTreeForTestingRenderingRequest.js @@ -20,6 +20,13 @@ ReactOnRails.clearHydratedStores(); var usedProps = typeof props === 'undefined' ? {"helloWorldData":{"name":"Mr. Server Side Rendering","\u003cscript\u003ewindow.alert('xss1');\u003c/script\u003e":"\u003cscript\u003ewindow.alert(\"xss2\");\u003c/script\u003e"}} : props; + + if (ReactOnRails.isRSCBundle) { + var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps); + usedProps = propsWithAsyncProps; + sharedExecutionContext.set("asyncPropsManager", asyncPropManager); + } + return ReactOnRails[ReactOnRails.isRSCBundle ? 'serverRenderRSCReactComponent' : 'streamServerRenderedReactComponent']({ name: componentName, domNodeId: 'AsyncComponentsTreeForTesting-react-component-0', diff --git a/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts index 7baab23a2e..4669c6431b 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts +++ b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts @@ -19,19 +19,13 @@ type RequestOptions = { renderRscPayload: boolean; }; -export const createForm = ({ +export const createRenderingRequest = ({ 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, @@ -45,6 +39,29 @@ export const createForm = ({ if (throwJsErrors) { renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); } + return renderingRequestCode; +}; + +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); + + const renderingRequestCode = createRenderingRequest({ + project, + commit, + props, + throwJsErrors, + componentName, + }); form.append('renderingRequest', renderingRequestCode); const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated'); @@ -73,7 +90,14 @@ export const createForm = ({ return form; }; -const getAppUrl = (app: ReturnType) => { +export const createUploadAssetsForm = (options: Partial = {}) => { + const requestForm = createForm(options); + requestForm.append('targetBundles[]', SERVER_BUNDLE_TIMESTAMP); + requestForm.append('targetBundles[]', RSC_BUNDLE_TIMESTAMP); + return requestForm; +}; + +export 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'); @@ -174,3 +198,66 @@ export const makeRequest = (app: ReturnType, options: Partial { + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + let cancelDataListener = () => {}; + if (timeout) { + timeoutId = setTimeout(() => { + cancelDataListener(); + reject(new Error(`Timeout after waiting for ${timeout}ms to get the next stream chunk`)); + }, timeout); + } + + const onData = (chunk: Buffer) => { + clearTimeout(timeoutId); + cancelDataListener(); + resolve(chunk.toString()); + }; + + const onError = (error: Error) => { + clearTimeout(timeoutId); + cancelDataListener(); + reject(error); + }; + + const onClose = () => { + reject(new Error('Stream Closed')); + }; + + cancelDataListener = () => { + stream.off('data', onData); + stream.off('error', onError); + stream.off('close', onClose); + }; + + stream.once('data', onData); + stream.once('error', onError); + if (stream.closed) { + onClose(); + } else { + stream.once('close', onClose); + } + }); +}; + +export const getNextChunk = async (stream: NodeJS.ReadableStream, options: { timeout?: number } = {}) => { + const receivedChunks: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + const chunk = await getNextChunkInternal(stream, options); + receivedChunks.push(chunk); + } catch (err) { + if (receivedChunks.length > 0) { + return receivedChunks.join(''); + } + throw err; + } + } +}; diff --git a/react_on_rails_pro/packages/node-renderer/tests/incrementalHtmlStreaming.test.ts b/react_on_rails_pro/packages/node-renderer/tests/incrementalHtmlStreaming.test.ts new file mode 100644 index 0000000000..01facda6d0 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/incrementalHtmlStreaming.test.ts @@ -0,0 +1,184 @@ +import http2 from 'http2'; +import * as fs from 'fs'; +import buildApp from '../src/worker'; +import config, { BUNDLE_PATH } from './testingNodeRendererConfigs'; +import * as errorReporter from '../src/shared/errorReporter'; +import { + createRenderingRequest, + createUploadAssetsForm, + getAppUrl, + getNextChunk, + RSC_BUNDLE_TIMESTAMP, + SERVER_BUNDLE_TIMESTAMP, +} from './httpRequestUtils'; +import packageJson from '../src/shared/packageJson'; + +const app = buildApp(config); + +beforeAll(async () => { + if (fs.existsSync(BUNDLE_PATH)) { + fs.rmSync(BUNDLE_PATH, { recursive: true, force: true }); + } + await app.ready(); + await app.listen({ port: 0 }); +}); + +afterAll(async () => { + await app.close(); +}); + +jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn()); + +const createHttpRequest = (bundleTimestamp: string = SERVER_BUNDLE_TIMESTAMP, pathSuffix = 'abc123') => { + const appUrl = getAppUrl(app); + const client = http2.connect(appUrl); + const request = client.request({ + ':method': 'POST', + ':path': `/bundles/${bundleTimestamp}/incremental-render/${pathSuffix}`, + 'content-type': 'application/x-ndjson', + }); + request.setEncoding('utf8'); + return { + request, + close: () => { + client.close(); + }, + }; +}; + +const createInitialObject = (bundleTimestamp: string = RSC_BUNDLE_TIMESTAMP, password = 'myPassword1') => ({ + gemVersion: packageJson.version, + protocolVersion: packageJson.protocolVersion, + password, + renderingRequest: createRenderingRequest({ componentName: 'AsyncPropsComponent' }), + onRequestClosedUpdateChunk: { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.endStream(); + })() + `, + }, + dependencyBundleTimestamps: [bundleTimestamp], +}); + +const makeRequest = async (options = {}) => { + const form = createUploadAssetsForm(options); + const appUrl = getAppUrl(app); + const client = http2.connect(appUrl); + const request = client.request({ + ':method': 'POST', + ':path': `/upload-assets`, + 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, + }); + request.setEncoding('utf8'); + + let status: number | undefined; + let body = ''; + + request.on('response', (headers) => { + status = headers[':status']; + }); + + request.on('data', (data: Buffer) => { + body += data.toString(); + }); + + form.pipe(request); + form.on('end', () => { + request.end(); + }); + + await new Promise((resolve, reject) => { + request.on('end', () => { + client.close(); + resolve(); + }); + request.on('error', (err) => { + client.close(); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + return { + status, + body, + }; +}; + +const waitForStatus = (request: http2.ClientHttp2Stream) => + new Promise((resolve) => { + request.on('response', (headers) => { + resolve(headers[':status']); + }); + }); + +it('uploads the bundles', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); +}); + +it('incremental render html', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); + + const { request, close } = createHttpRequest(); + const initialRequestObject = createInitialObject(); + request.write(`${JSON.stringify(initialRequestObject)}\n`); + + await expect(waitForStatus(request)).resolves.toBe(200); + await expect(getNextChunk(request)).resolves.toContain('AsyncPropsComponent is a renderFunction'); + + const updateChunk = { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp("books", ["Tale of two towns", "Pro Git"]); + })() + `, + }; + request.write(`${JSON.stringify(updateChunk)}\n`); + await expect(getNextChunk(request)).resolves.toContain('Tale of two towns'); + + const updateChunk2 = { + bundleTimestamp: RSC_BUNDLE_TIMESTAMP, + updateChunk: ` + (function(){ + var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager"); + asyncPropsManager.setProp("researches", ["AI effect on productivity", "Pro Git"]); + })() + `, + }; + request.write(`${JSON.stringify(updateChunk2)}\n`); + request.end(); + await expect(getNextChunk(request)).resolves.toContain('AI effect on productivity'); + + await expect(getNextChunk(request)).rejects.toThrow('Stream Closed'); + close(); +}); + +// TODO: fix the problem of having a global shared `runOnOtherBundle` function +it.skip('raises an error if a specific async prop is not sent', async () => { + const { status, body } = await makeRequest(); + expect(body).toBe(''); + expect(status).toBe(200); + + const { request, close } = createHttpRequest(); + const initialRequestObject = createInitialObject(); + request.write(`${JSON.stringify(initialRequestObject)}\n`); + + await expect(waitForStatus(request)).resolves.toBe(200); + await expect(getNextChunk(request)).resolves.toContain('AsyncPropsComponent is a renderFunction'); + + request.end(); + await expect(getNextChunk(request)).resolves.toContain( + 'The async prop \\"researches\\" is not received. Esnure to send the async prop from ruby side', + ); + + await expect(getNextChunk(request)).rejects.toThrow('Stream Closed'); + close(); +}); diff --git a/react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.js b/react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.ts similarity index 77% rename from react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.js rename to react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.ts index 97965053fe..d236f84cce 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.js +++ b/react_on_rails_pro/packages/node-renderer/tests/testingNodeRendererConfigs.ts @@ -1,16 +1,18 @@ import fs from 'fs'; import { env } from 'process'; +import { LevelWithSilent } from 'pino'; +import { Config } from '../src/shared/configBuilder'; -const BUNDLE_PATH = './tmp/node-renderer-bundles-test'; +export const BUNDLE_PATH = './tmp/node-renderer-bundles-test'; if (fs.existsSync(BUNDLE_PATH)) { fs.rmSync(BUNDLE_PATH, { recursive: true, force: true }); } -const config = { +const config: Partial = { // This is the default but avoids searching for the Rails root serverBundleCachePath: BUNDLE_PATH, - port: env.RENDERER_PORT || 3800, // Listen at RENDERER_PORT env value or default port 3800 - logLevel: env.RENDERER_LOG_LEVEL || 'info', + port: (env.RENDERER_PORT && parseInt(env.RENDERER_PORT, 10)) || 3800, // Listen at RENDERER_PORT env value or default port 3800 + logLevel: (env.RENDERER_LOG_LEVEL as LevelWithSilent | undefined) || 'info', // See value in /config/initializers/react_on_rails_pro.rb. Should use env value in real app. password: 'myPassword1', diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx new file mode 100644 index 0000000000..f8f20d0363 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/AsyncPropsComponent.tsx @@ -0,0 +1,56 @@ +/// + +import * as React from 'react'; +import { Suspense } from 'react'; +import { WithAsyncProps } from 'react-on-rails-pro'; + +type SyncPropsType = { + name: string; + age: number; + description: string; +}; + +type AsyncPropsType = { + books: string[]; + researches: string[]; +}; + +type PropsType = WithAsyncProps; + +const AsyncArrayComponent = async ({ items }: { items: Promise }) => { + const resolvedItems = await items; + + return ( +
    + {resolvedItems.map((value) => ( +
  1. {value}
  2. + ))} +
+ ); +}; + +const AsyncPropsComponent = ({ name, age, description, getReactOnRailsAsyncProp }: PropsType) => { + const booksPromise = getReactOnRailsAsyncProp('books'); + const researchesPromise = getReactOnRailsAsyncProp('researches'); + + return ( +
+

Async Props Component

+

Name: {name}

+

Age: {age}

+

Description: {description}

+ +

Books

+ Loading Books...

}> + +
+ +

Researches

+ Loading Researches...

}> + +
+
+ ); +}; + +export default AsyncPropsComponent;