Skip to content

Commit d9c77fa

Browse files
Add AsyncPropManager to react-on-rails-pro package (#2049)
1 parent 8170fd8 commit d9c77fa

File tree

16 files changed

+712
-21
lines changed

16 files changed

+712
-21
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (c) 2025 Shakacode LLC
3+
*
4+
* This file is NOT licensed under the MIT (open source) license.
5+
* It is part of the React on Rails Pro offering and is licensed separately.
6+
*
7+
* Unauthorized copying, modification, distribution, or use of this file,
8+
* via any medium, is strictly prohibited without a valid license agreement
9+
* from Shakacode LLC.
10+
*
11+
* For licensing terms, please see:
12+
* https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md
13+
*/
14+
15+
type PromiseController = {
16+
promise: Promise<unknown>;
17+
resolve: (propValue: unknown) => void;
18+
reject: (reason: unknown) => void;
19+
resolved: boolean;
20+
};
21+
22+
class AsyncPropsManager {
23+
private isClosed: boolean = false;
24+
25+
private propNameToPromiseController = new Map<string, PromiseController>();
26+
27+
// The function is not converted to an async function to ensure that:
28+
// The function returns the same promise on successful scenario, so it can be used inside async react component
29+
// Or with the `use` hook without causing an infinite loop or flicks during rendering
30+
getProp(propName: string) {
31+
const promiseController = this.getOrCreatePromiseController(propName);
32+
if (!promiseController) {
33+
return Promise.reject(AsyncPropsManager.getNoPropFoundError(propName));
34+
}
35+
36+
return promiseController.promise;
37+
}
38+
39+
setProp(propName: string, propValue: unknown) {
40+
const promiseController = this.getOrCreatePromiseController(propName);
41+
if (!promiseController) {
42+
throw new Error(`Can't set the async prop "${propName}" because the stream is already closed`);
43+
}
44+
45+
promiseController.resolve(propValue);
46+
}
47+
48+
endStream() {
49+
if (this.isClosed) {
50+
return;
51+
}
52+
53+
this.isClosed = true;
54+
this.propNameToPromiseController.forEach((promiseController, propName) => {
55+
if (!promiseController.resolved) {
56+
promiseController.reject(AsyncPropsManager.getNoPropFoundError(propName));
57+
}
58+
});
59+
}
60+
61+
private getOrCreatePromiseController(propName: string) {
62+
const promiseController = this.propNameToPromiseController.get(propName);
63+
if (promiseController) {
64+
return promiseController;
65+
}
66+
67+
if (this.isClosed) {
68+
return undefined;
69+
}
70+
71+
const partialPromiseController = {
72+
resolved: false,
73+
};
74+
75+
let resolvePromise: PromiseController['resolve'] = () => {};
76+
let rejectPromise: PromiseController['reject'] = () => {};
77+
const promise = new Promise((resolve, reject) => {
78+
resolvePromise = resolve;
79+
rejectPromise = reject;
80+
});
81+
82+
const newPromiseController = Object.assign(partialPromiseController, {
83+
promise,
84+
resolve: resolvePromise,
85+
reject: rejectPromise,
86+
});
87+
this.propNameToPromiseController.set(propName, newPromiseController);
88+
return newPromiseController;
89+
}
90+
91+
private static getNoPropFoundError(propName: string) {
92+
return new Error(
93+
`The async prop "${propName}" is not received. Esnure to send the async prop from ruby side`,
94+
);
95+
}
96+
}
97+
98+
export default AsyncPropsManager;

packages/react-on-rails-pro/src/ReactOnRailsRSC.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { convertToError } from 'react-on-rails/serverRenderUtils';
2626
import handleError from './handleErrorRSC.ts';
2727
import ReactOnRails from './ReactOnRails.full.ts';
28+
import AsyncPropsManager from './AsyncPropsManager.ts';
2829

2930
import {
3031
streamServerRenderedComponent,
@@ -104,6 +105,26 @@ ReactOnRails.serverRenderRSCReactComponent = (options: RSCRenderParams) => {
104105
}
105106
};
106107

108+
function addAsyncPropsCapabilityToComponentProps<
109+
AsyncPropsType extends Record<string, unknown>,
110+
PropsType extends Record<string, unknown>,
111+
>(props: PropsType) {
112+
const asyncPropManager = new AsyncPropsManager();
113+
const propsAfterAddingAsyncProps = {
114+
...props,
115+
getReactOnRailsAsyncProp: <PropName extends keyof AsyncPropsType>(propName: PropName) => {
116+
return asyncPropManager.getProp(propName as string) as Promise<AsyncPropsType[PropName]>;
117+
},
118+
};
119+
120+
return {
121+
asyncPropManager,
122+
props: propsAfterAddingAsyncProps,
123+
};
124+
}
125+
126+
ReactOnRails.addAsyncPropsCapabilityToComponentProps = addAsyncPropsCapabilityToComponentProps;
127+
107128
ReactOnRails.isRSCBundle = true;
108129

109130
export * from 'react-on-rails/types';

packages/react-on-rails-pro/src/createReactOnRailsPro.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type ReactOnRailsProSpecificFunctions = Pick<
4545
| 'reactOnRailsStoreLoaded'
4646
| 'streamServerRenderedReactComponent'
4747
| 'serverRenderRSCReactComponent'
48+
| 'addAsyncPropsCapabilityToComponentProps'
4849
>;
4950

5051
// Pro client startup with immediate hydration support
@@ -133,6 +134,10 @@ export default function createReactOnRailsPro(
133134
serverRenderRSCReactComponent(): any {
134135
throw new Error('serverRenderRSCReactComponent is supported in RSC bundle only');
135136
},
137+
138+
addAsyncPropsCapabilityToComponentProps() {
139+
throw new Error('addAsyncPropsCapabilityToComponentProps is supported in RSC bundle only');
140+
},
136141
};
137142

138143
// Type assertion is safe here because:
@@ -153,6 +158,11 @@ export default function createReactOnRailsPro(
153158
reactOnRailsPro.serverRenderRSCReactComponent;
154159
}
155160

161+
if (reactOnRailsPro.addAsyncPropsCapabilityToComponentProps) {
162+
reactOnRailsProSpecificFunctions.addAsyncPropsCapabilityToComponentProps =
163+
reactOnRailsPro.addAsyncPropsCapabilityToComponentProps;
164+
}
165+
156166
// Assign Pro-specific functions to the ReactOnRailsPro object using Object.assign
157167
// This pattern ensures we add exactly what's defined in the type, nothing more, nothing less
158168
Object.assign(reactOnRailsPro, reactOnRailsProSpecificFunctions);
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import AsyncPropsManager from '../src/AsyncPropsManager.ts';
2+
3+
describe('Access AsyncPropManager prop before setting it', () => {
4+
let manager: AsyncPropsManager;
5+
let getPropPromise: Promise<unknown>;
6+
7+
beforeEach(() => {
8+
manager = new AsyncPropsManager();
9+
getPropPromise = manager.getProp('randomProp');
10+
manager.setProp('randomProp', 'Fake Value');
11+
});
12+
13+
it('returns the same value', async () => {
14+
await expect(getPropPromise).resolves.toBe('Fake Value');
15+
});
16+
17+
it('returns the same promise on success scenarios', async () => {
18+
const secondGetPropPromise = manager.getProp('randomProp');
19+
expect(secondGetPropPromise).toBe(getPropPromise);
20+
await expect(getPropPromise).resolves.toBe('Fake Value');
21+
});
22+
23+
it('allows accessing multiple props', async () => {
24+
const getSecondPropPromise = manager.getProp('secondRandomProp');
25+
await expect(getPropPromise).resolves.toBe('Fake Value');
26+
manager.setProp('secondRandomProp', 'Another Fake Value');
27+
await expect(getSecondPropPromise).resolves.toBe('Another Fake Value');
28+
});
29+
});
30+
31+
describe('Access AsyncPropManager prop after setting it', () => {
32+
let manager: AsyncPropsManager;
33+
let getPropPromise: Promise<unknown>;
34+
35+
beforeEach(() => {
36+
manager = new AsyncPropsManager();
37+
manager.setProp('randomProp', 'Value got after setting');
38+
getPropPromise = manager.getProp('randomProp');
39+
});
40+
41+
it('can set the prop before getting it', async () => {
42+
await expect(getPropPromise).resolves.toBe('Value got after setting');
43+
});
44+
45+
it('returns the same promise on success scenarios', async () => {
46+
const secondGetPropPromise = manager.getProp('randomProp');
47+
expect(secondGetPropPromise).toBe(getPropPromise);
48+
await expect(getPropPromise).resolves.toBe('Value got after setting');
49+
});
50+
51+
it('allows accessing multiple props', async () => {
52+
manager.setProp('secondRandomProp', 'Another Fake Value');
53+
const getSecondPropPromise = manager.getProp('secondRandomProp');
54+
await expect(getPropPromise).resolves.toBe('Value got after setting');
55+
await expect(getSecondPropPromise).resolves.toBe('Another Fake Value');
56+
});
57+
});
58+
59+
describe('Access AsyncPropManager prop after closing the stream', () => {
60+
let manager: AsyncPropsManager;
61+
let getPropPromise: Promise<unknown>;
62+
63+
beforeEach(() => {
64+
manager = new AsyncPropsManager();
65+
manager.setProp('prop accessed after closing', 'Value got after closing the stream');
66+
manager.endStream();
67+
getPropPromise = manager.getProp('prop accessed after closing');
68+
});
69+
70+
it('can set the prop before getting it', async () => {
71+
await expect(getPropPromise).resolves.toBe('Value got after closing the stream');
72+
});
73+
74+
it('returns the same promise on success scenarios', async () => {
75+
const secondGetPropPromise = manager.getProp('prop accessed after closing');
76+
expect(secondGetPropPromise).toBe(getPropPromise);
77+
await expect(getPropPromise).resolves.toBe('Value got after closing the stream');
78+
});
79+
});
80+
81+
describe('Access non sent AsyncPropManager prop', () => {
82+
it('throws an error if non-existing prop is sent after closing the stream', async () => {
83+
const manager = new AsyncPropsManager();
84+
manager.endStream();
85+
await expect(manager.getProp('Non Existing Prop')).rejects.toThrow(
86+
/The async prop "Non Existing Prop" is not received/,
87+
);
88+
});
89+
90+
it('rejects getPropPromise if the stream is closed before getting the prop value', async () => {
91+
const manager = new AsyncPropsManager();
92+
const getPropPromise = manager.getProp('wrongProp');
93+
manager.endStream();
94+
await expect(getPropPromise).rejects.toThrow(/The async prop "wrongProp" is not received/);
95+
});
96+
97+
it('throws an error if a prop is set after closing the stream', () => {
98+
const manager = new AsyncPropsManager();
99+
manager.endStream();
100+
expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow(
101+
/Can't set the async prop "wrongProp" because the stream is already closed/,
102+
);
103+
});
104+
});
105+
106+
describe('Accessing AsyncPropManager prop in complex scenarios', () => {
107+
it('accepts multiple received props and reject multiple non sent props', async () => {
108+
const manager = new AsyncPropsManager();
109+
const accessBeforeSetPromise = manager.getProp('accessBeforeSetProp');
110+
const secondAccessBeforeSetPromise = manager.getProp('secondAccessBeforeSetProp');
111+
const nonExistingPropPromise = manager.getProp('nonExistingProp');
112+
113+
// Setting and getting props
114+
manager.setProp('setBeforeAccessProp', 'Set Before Access Prop Value');
115+
manager.setProp('accessBeforeSetProp', 'Access Before Set Prop Value');
116+
await expect(accessBeforeSetPromise).resolves.toBe('Access Before Set Prop Value');
117+
await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value');
118+
119+
// Setting another prop
120+
manager.setProp('secondAccessBeforeSetProp', 'Second Access Before Set Prop Value');
121+
await expect(secondAccessBeforeSetPromise).resolves.toBe('Second Access Before Set Prop Value');
122+
123+
// Ensure all props return the same promise
124+
expect(manager.getProp('accessBeforeSetProp')).toBe(manager.getProp('accessBeforeSetProp'));
125+
expect(manager.getProp('secondAccessBeforeSetProp')).toBe(manager.getProp('secondAccessBeforeSetProp'));
126+
expect(manager.getProp('setBeforeAccessProp')).toBe(manager.getProp('setBeforeAccessProp'));
127+
128+
// Access props one more time
129+
await expect(manager.getProp('setBeforeAccessProp')).resolves.toBe('Set Before Access Prop Value');
130+
await expect(manager.getProp('accessBeforeSetProp')).resolves.toBe('Access Before Set Prop Value');
131+
132+
// Non existing props
133+
manager.endStream();
134+
await expect(nonExistingPropPromise).rejects.toThrow(/The async prop "nonExistingProp" is not received/);
135+
await expect(manager.getProp('wrongProp')).rejects.toThrow(/The async prop "wrongProp" is not received/);
136+
137+
// Setting after closing
138+
expect(() => manager.setProp('wrongProp', 'Nothing')).toThrow(
139+
/Can't set the async prop "wrongProp" because the stream is already closed/,
140+
);
141+
});
142+
});

packages/react-on-rails-pro/tests/testUtils.js renamed to packages/react-on-rails-pro/tests/testUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { Readable } from 'stream';
99
* }} Object containing the stream and push function
1010
*/
1111
export const createNodeReadableStream = () => {
12-
const pendingChunks = [];
13-
let pushFn;
12+
const pendingChunks: unknown[] = [];
13+
let pushFn: (chunk: unknown) => void;
1414
const stream = new Readable({
1515
read() {
1616
pushFn = this.push.bind(this);
@@ -20,7 +20,7 @@ export const createNodeReadableStream = () => {
2020
},
2121
});
2222

23-
const push = (chunk) => {
23+
const push = (chunk: unknown) => {
2424
if (pushFn) {
2525
pushFn(chunk);
2626
} else {

packages/react-on-rails/src/base/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type BaseClientObjectType = Omit<
5151
| 'reactOnRailsStoreLoaded'
5252
| 'streamServerRenderedReactComponent'
5353
| 'serverRenderRSCReactComponent'
54+
| 'addAsyncPropsCapabilityToComponentProps'
5455
>;
5556

5657
// Cache to track created objects and their registries

packages/react-on-rails/src/createReactOnRails.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ReactOnRailsCoreSpecificFunctions = Pick<
2222
| 'reactOnRailsStoreLoaded'
2323
| 'streamServerRenderedReactComponent'
2424
| 'serverRenderRSCReactComponent'
25+
| 'addAsyncPropsCapabilityToComponentProps'
2526
>;
2627

2728
export default function createReactOnRails(
@@ -76,6 +77,10 @@ export default function createReactOnRails(
7677
serverRenderRSCReactComponent(): any {
7778
throw new Error('serverRenderRSCReactComponent requires react-on-rails-pro package');
7879
},
80+
81+
addAsyncPropsCapabilityToComponentProps() {
82+
throw new Error('addAsyncPropsCapabilityToComponentProps requires react-on-rails-pro package');
83+
},
7984
};
8085

8186
// Type assertion is safe here because:

0 commit comments

Comments
 (0)