Skip to content

Commit 12afe85

Browse files
add AsyncPropManager to react-on-rails-pro package
1 parent 8170fd8 commit 12afe85

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
this.isClosed = true;
50+
this.propNameToPromiseController.forEach((promiseController, propName) => {
51+
if (!promiseController.resolved) {
52+
promiseController.reject(AsyncPropsManager.getNoPropFoundError(propName));
53+
}
54+
});
55+
}
56+
57+
private getOrCreatePromiseController(propName: string) {
58+
const promiseController = this.propNameToPromiseController.get(propName);
59+
if (promiseController) {
60+
return promiseController;
61+
}
62+
63+
if (this.isClosed) {
64+
return undefined;
65+
}
66+
67+
const partialPromiseController = {
68+
resolved: false,
69+
};
70+
71+
let resolvePromise: PromiseController['resolve'] = () => {};
72+
let rejectPromise: PromiseController['reject'] = () => {};
73+
const promise = new Promise((resolve, reject) => {
74+
resolvePromise = resolve;
75+
rejectPromise = reject;
76+
});
77+
78+
const newPromiseController = Object.assign(partialPromiseController, {
79+
promise,
80+
resolve: resolvePromise,
81+
reject: rejectPromise,
82+
});
83+
this.propNameToPromiseController.set(propName, newPromiseController);
84+
return newPromiseController;
85+
}
86+
87+
private static getNoPropFoundError(propName: string) {
88+
return new Error(
89+
`The async prop "${propName}" is not received. Esnure to send the async prop from ruby side`,
90+
);
91+
}
92+
}
93+
94+
export default AsyncPropsManager;
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+
});

0 commit comments

Comments
 (0)