Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/react-on-rails-pro/src/AsyncPropsManager.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
resolve: (propValue: unknown) => void;
reject: (reason: unknown) => void;
resolved: boolean;
};

class AsyncPropsManager {
private isClosed: boolean = false;

private propNameToPromiseController = new Map<string, PromiseController>();

// 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;
21 changes: 21 additions & 0 deletions packages/react-on-rails-pro/src/ReactOnRailsRSC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,6 +105,26 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
}
};

function addAsyncPropsCapabilityToComponentProps<
AsyncPropsType extends Record<string, unknown>,
PropsType extends Record<string, unknown>,
>(props: PropsType) {
const asyncPropManager = new AsyncPropsManager();
const propsAfterAddingAsyncProps = {
...props,
getReactOnRailsAsyncProp: <PropName extends keyof AsyncPropsType>(propName: PropName) => {
return asyncPropManager.getProp(propName as string) as Promise<AsyncPropsType[PropName]>;
},
};

return {
asyncPropManager,
props: propsAfterAddingAsyncProps,
};
}

ReactOnRails.addAsyncPropsCapabilityToComponentProps = addAsyncPropsCapabilityToComponentProps;

ReactOnRails.isRSCBundle = true;

export * from 'react-on-rails/types';
Expand Down
10 changes: 10 additions & 0 deletions packages/react-on-rails-pro/src/createReactOnRailsPro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type ReactOnRailsProSpecificFunctions = Pick<
| 'reactOnRailsStoreLoaded'
| 'streamServerRenderedReactComponent'
| 'serverRenderRSCReactComponent'
| 'addAsyncPropsCapabilityToComponentProps'
>;

// Pro client startup with immediate hydration support
Expand Down Expand Up @@ -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:
Expand All @@ -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);
Expand Down
142 changes: 142 additions & 0 deletions packages/react-on-rails-pro/tests/AsyncPropManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import AsyncPropsManager from '../src/AsyncPropsManager.ts';

describe('Access AsyncPropManager prop before setting it', () => {
let manager: AsyncPropsManager;
let getPropPromise: Promise<unknown>;

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<unknown>;

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<unknown>;

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/,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -20,7 +20,7 @@ export const createNodeReadableStream = () => {
},
});

const push = (chunk) => {
const push = (chunk: unknown) => {
if (pushFn) {
pushFn(chunk);
} else {
Expand Down
1 change: 1 addition & 0 deletions packages/react-on-rails/src/base/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type BaseClientObjectType = Omit<
| 'reactOnRailsStoreLoaded'
| 'streamServerRenderedReactComponent'
| 'serverRenderRSCReactComponent'
| 'addAsyncPropsCapabilityToComponentProps'
>;

// Cache to track created objects and their registries
Expand Down
5 changes: 5 additions & 0 deletions packages/react-on-rails/src/createReactOnRails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ReactOnRailsCoreSpecificFunctions = Pick<
| 'reactOnRailsStoreLoaded'
| 'streamServerRenderedReactComponent'
| 'serverRenderRSCReactComponent'
| 'addAsyncPropsCapabilityToComponentProps'
>;

export default function createReactOnRails(
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading