Skip to content

Commit 521c5b3

Browse files
Enhance error handling and validation in RSC-related modules
1 parent 19fc4a2 commit 521c5b3

File tree

5 files changed

+160
-42
lines changed

5 files changed

+160
-42
lines changed

node_package/src/PostSSRHookTracker.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,59 @@ type PostSSRHook = () => void;
55
*
66
* This class provides a local alternative to the global hook management,
77
* allowing each request to have its own isolated hook tracker without sharing state.
8+
*
9+
* The tracker ensures that:
10+
* - Hooks are executed exactly once when SSR ends
11+
* - No hooks can be added after SSR has completed
12+
* - Proper cleanup occurs to prevent memory leaks
813
*/
914
class PostSSRHookTracker {
1015
private hooks: PostSSRHook[] = [];
1116

17+
private hasSSREnded = false;
18+
1219
/**
1320
* Adds a hook to be executed when SSR ends for this request.
1421
*
1522
* @param hook - Function to call when SSR ends
23+
* @throws Error if called after SSR has already ended
1624
*/
1725
addPostSSRHook(hook: PostSSRHook): void {
26+
if (this.hasSSREnded) {
27+
console.error(
28+
'Cannot add post-SSR hook: SSR has already ended for this request. ' +
29+
'Hooks must be registered before or during the SSR process.',
30+
);
31+
return;
32+
}
33+
1834
this.hooks.push(hook);
1935
}
2036

2137
/**
2238
* Notifies all registered hooks that SSR has ended and clears the hook list.
23-
* This should be called when server-side rendering is complete.
39+
* This should be called exactly once when server-side rendering is complete.
40+
*
41+
* @throws Error if called multiple times
2442
*/
2543
notifySSREnd(): void {
26-
this.hooks.forEach((hook) => hook());
27-
this.hooks = [];
28-
}
44+
if (this.hasSSREnded) {
45+
console.warn('notifySSREnd() called multiple times. This may indicate a bug in the SSR lifecycle.');
46+
return;
47+
}
2948

30-
/**
31-
* Clears all hooks without executing them.
32-
* Should be called if the request is aborted or cleanup is needed.
33-
*/
34-
clear(): void {
49+
this.hasSSREnded = true;
50+
51+
// Execute all hooks and handle any errors gracefully
52+
this.hooks.forEach((hook, index) => {
53+
try {
54+
hook();
55+
} catch (error) {
56+
console.error(`Error executing post-SSR hook ${index}:`, error);
57+
}
58+
});
59+
60+
// Clear hooks to free memory
3561
this.hooks = [];
3662
}
3763
}

node_package/src/RSCRequestTracker.ts

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,22 @@ class RSCRequestTracker {
3636
* Clears all streams and callbacks for this request.
3737
* Should be called when the request is complete to ensure proper cleanup,
3838
* though garbage collection will handle cleanup automatically when the tracker goes out of scope.
39+
*
40+
* This method is safe to call multiple times and will handle any errors during cleanup gracefully.
3941
*/
4042
clear(): void {
4143
// Close any active streams before clearing
42-
this.streams.forEach(({ stream }) => {
43-
if (typeof (stream as Readable).destroy === 'function') {
44-
(stream as Readable).destroy();
44+
this.streams.forEach(({ stream, componentName }, index) => {
45+
try {
46+
if (stream && typeof (stream as Readable).destroy === 'function') {
47+
(stream as Readable).destroy();
48+
}
49+
} catch (error) {
50+
// Log the error but don't throw to avoid disrupting cleanup of other streams
51+
console.warn(
52+
`Warning: Error while destroying RSC stream for component "${componentName}" at index ${index}:`,
53+
error,
54+
);
4555
}
4656
});
4757

@@ -83,31 +93,50 @@ class RSCRequestTracker {
8393
* @param componentName - Name of the server component
8494
* @param props - Props for the server component
8595
* @returns A stream of the RSC payload
96+
* @throws Error if generateRSCPayload is not available or fails
8697
*/
8798
async getRSCPayloadStream(componentName: string, props: unknown): Promise<NodeJS.ReadableStream> {
88-
const stream = await generateRSCPayload(componentName, props, this.railsContext);
89-
90-
// Tee stream to allow for multiple consumers:
91-
// 1. stream1 - Used by React's runtime to perform server-side rendering
92-
// 2. stream2 - Used by react-on-rails to embed the RSC payloads
93-
// into the HTML stream for client-side hydration
94-
const stream1 = new PassThrough();
95-
stream.pipe(stream1);
96-
const stream2 = new PassThrough();
97-
stream.pipe(stream2);
98-
99-
const streamInfo: RSCPayloadStreamInfo = {
100-
componentName,
101-
props,
102-
stream: stream2,
103-
};
104-
105-
this.streams.push(streamInfo);
106-
107-
// Notify callbacks about the new stream in a sync manner to maintain proper hydration timing
108-
this.callbacks.forEach((callback) => callback(streamInfo));
109-
110-
return stream1;
99+
// Validate that the global generateRSCPayload function is available
100+
if (typeof generateRSCPayload !== 'function') {
101+
throw new Error(
102+
'generateRSCPayload is not defined. Please ensure that you are using at least version 4.0.0 of ' +
103+
'React on Rails Pro and the Node renderer, and that ReactOnRailsPro.configuration.enable_rsc_support ' +
104+
'is set to true.',
105+
);
106+
}
107+
108+
try {
109+
const stream = await generateRSCPayload(componentName, props, this.railsContext);
110+
111+
// Tee stream to allow for multiple consumers:
112+
// 1. stream1 - Used by React's runtime to perform server-side rendering
113+
// 2. stream2 - Used by react-on-rails to embed the RSC payloads
114+
// into the HTML stream for client-side hydration
115+
const stream1 = new PassThrough();
116+
stream.pipe(stream1);
117+
const stream2 = new PassThrough();
118+
stream.pipe(stream2);
119+
120+
const streamInfo: RSCPayloadStreamInfo = {
121+
componentName,
122+
props,
123+
stream: stream2,
124+
};
125+
126+
this.streams.push(streamInfo);
127+
128+
// Notify callbacks about the new stream in a sync manner to maintain proper hydration timing
129+
this.callbacks.forEach((callback) => callback(streamInfo));
130+
131+
return stream1;
132+
} catch (error) {
133+
// Provide a more helpful error message that includes context
134+
throw new Error(
135+
`Failed to generate RSC payload for component "${componentName}": ${
136+
error instanceof Error ? error.message : String(error)
137+
}`,
138+
);
139+
}
111140
}
112141

113142
/**

node_package/src/getReactServerComponent.client.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,39 @@ const createFromFetch = async (fetchPromise: Promise<Response>) => {
4242
*
4343
* @param props - Object containing component name, props, and railsContext
4444
* @returns A Promise resolving to the rendered React element
45+
* @throws Error if RSC payload generation URL path is not configured or network request fails
4546
*/
4647
const fetchRSC = ({ componentName, componentProps, railsContext }: ClientGetReactServerComponentProps) => {
47-
const propsString = JSON.stringify(componentProps);
4848
const { rscPayloadGenerationUrlPath } = railsContext;
49-
const strippedUrlPath = rscPayloadGenerationUrlPath?.replace(/^\/|\/$/g, '');
50-
const encodedParams = new URLSearchParams({ props: propsString }).toString();
51-
return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?${encodedParams}`));
49+
50+
if (!rscPayloadGenerationUrlPath) {
51+
throw new Error(
52+
`Cannot fetch RSC payload for component "${componentName}": rscPayloadGenerationUrlPath is not configured. ` +
53+
'Please ensure React Server Components support is properly enabled and configured.',
54+
);
55+
}
56+
57+
try {
58+
const propsString = JSON.stringify(componentProps);
59+
const strippedUrlPath = rscPayloadGenerationUrlPath.replace(/^\/|\/$/g, '');
60+
const encodedParams = new URLSearchParams({ props: propsString }).toString();
61+
const fetchUrl = `/${strippedUrlPath}/${componentName}?${encodedParams}`;
62+
63+
return createFromFetch(fetch(fetchUrl)).catch((error: unknown) => {
64+
throw new Error(
65+
`Failed to fetch RSC payload for component "${componentName}" from "${fetchUrl}": ${
66+
error instanceof Error ? error.message : String(error)
67+
}`,
68+
);
69+
});
70+
} catch (error: unknown) {
71+
// Handle JSON.stringify errors or other synchronous errors
72+
throw new Error(
73+
`Failed to prepare RSC request for component "${componentName}": ${
74+
error instanceof Error ? error.message : String(error)
75+
}`,
76+
);
77+
}
5278
};
5379

5480
const createRSCStreamFromArray = (payloads: string[]) => {

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,22 @@ type StreamRenderer<T, P extends RenderParams> = (
220220
streamingTrackers: StreamingTrackers,
221221
) => T;
222222

223+
/**
224+
* This module implements request-scoped tracking for React Server Components (RSC)
225+
* and post-SSR hooks using local tracker instances per request.
226+
*
227+
* DESIGN PRINCIPLES:
228+
* - Each request gets its own PostSSRHookTracker and RSCRequestTracker instances
229+
* - State is automatically garbage collected when request completes
230+
* - No shared state between concurrent requests
231+
* - Simple, predictable cleanup lifecycle
232+
*
233+
* TRACKER RESPONSIBILITIES:
234+
* - PostSSRHookTracker: Manages hooks that run after SSR completes
235+
* - RSCRequestTracker: Handles RSC payload generation and stream tracking
236+
* - Both inject their capabilities into the Rails context for component access
237+
*/
238+
223239
export const streamServerRenderedComponent = <T, P extends RenderParams>(
224240
options: P,
225241
renderStrategy: StreamRenderer<T, P>,

node_package/src/utils.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,36 @@ const customFetch = (...args: Parameters<typeof fetch>) => {
1010

1111
export { customFetch as fetch };
1212

13+
/**
14+
* Creates a unique cache key for RSC payloads to prevent collisions between component instances.
15+
*
16+
* This function generates cache keys that ensure:
17+
* 1. Different components have different keys
18+
* 2. Same components with different props have different keys
19+
* 3. Multiple instances of the same component on the same rails view have different keys
20+
*
21+
* @param componentName - Name of the React Server Component
22+
* @param componentProps - Props passed to the component (serialized to JSON)
23+
* @param domNodeId - DOM node ID of the parent react component rendered at the rails view (prevents instance collisions)
24+
* @returns A unique cache key string
25+
* @throws Error if domNodeId is missing, which could lead to cache collisions
26+
*/
1327
export const createRSCPayloadKey = (
1428
componentName: string,
1529
componentProps: unknown,
1630
domNodeId: string | undefined,
1731
) => {
1832
if (!domNodeId) {
19-
console.warn(
20-
'Warning: domNodeId is required when using React Server Components to ensure unique RSC payload caching and prevent conflicts between multiple instances of the same component.',
21-
);
33+
const errorMessage =
34+
'domNodeId is required when using React Server Components to ensure unique RSC payload caching ' +
35+
'and prevent conflicts between multiple instances of the same component at the same rails view. ' +
36+
'This could lead to incorrect hydration and component state issues.';
37+
38+
if (process.env.NODE_ENV === 'development') {
39+
throw new Error(`RSC Cache Key Error: ${errorMessage}`);
40+
} else {
41+
console.warn(`Warning: ${errorMessage}`);
42+
}
2243
}
2344

2445
return `${componentName}-${JSON.stringify(componentProps)}-${domNodeId}`;

0 commit comments

Comments
 (0)