-
-
Notifications
You must be signed in to change notification settings - Fork 636
[WIP] Add ability to render server components inside client components (add support for react-router) #1736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server
Are you sure you want to change the base?
Conversation
…uest tracking - Introduced renderRequestId in render options to uniquely identify each render request. - Updated server rendering code to include renderRequestId in the context. - Enhanced client-side rendering to handle and log renderRequestId. - Modified type definitions to accommodate the new renderRequestId field in RailsContext. - Improved debugging output in RailsContext component for better visibility of context data.
This reverts commit cd57337.
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the WalkthroughThis update introduces comprehensive support for React Server Components (RSC) within the React on Rails framework. It adds new configuration options, helper methods, and internal APIs to manage RSC payload generation, streaming, and client/server integration. Several new TypeScript modules and React components are implemented to handle RSC rendering, context management, and payload injection into HTML streams. The configuration, documentation, and test suites are updated to cover these new features. The package exports are expanded to facilitate conditional server/client module resolution. Legacy or redundant files are removed, and several files are refactored for improved clarity and maintainability. Changes
Sequence Diagram(s)sequenceDiagram
participant Rails as Rails (Server)
participant NodeRenderer as Node Renderer
participant ReactOnRails as ReactOnRails (Node)
participant Client as Browser/Client
Rails->>NodeRenderer: Request server-side render with RSC
NodeRenderer->>ReactOnRails: getRSCPayloadStream(component, props, context)
ReactOnRails->>ReactOnRails: Generate RSC payload stream
ReactOnRails->>NodeRenderer: Return RSC payload stream
NodeRenderer->>Rails: Stream rendered HTML + inject RSC payload scripts
Rails->>Client: Serve HTML with embedded RSC payload
Client->>Client: Hydrate using RSCClientRoot & context
Client->>Rails: Optionally fetch more RSC payloads for hydration
Possibly related PRs
Suggested reviewers
Poem
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Caution
Inline review comments failed to post. This is likely due to GitHub's limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 17
🧹 Nitpick comments (22)
node_package/src/reactApis.cts (1)
55-61
: Good runtime check for React compatibility.The function properly verifies React 19+ compatibility by checking for the
use
hook, which is required for React Server Components.Consider enhancing the error message to be more actionable:
- 'React.use is not defined. Please ensure you are using React 19 to use server components.', + 'React.use is not defined. Please ensure you are using React 19+ to use server components. Update your package.json dependencies and run npm/yarn install.',node_package/src/ReactOnRailsRSC.ts (2)
27-33
: Error handling improved, but message could be more specific.The validation of required properties in
railsContext
is good, but the error message could be more descriptive about what's missing.- throw new Error('Rails context is not available'); + throw new Error('Rails context or reactClientManifestFileName is not available. Both are required for RSC rendering.');
75-75
: Missing documentation for new flag.The
isRSCBundle
flag is added without any comments explaining its purpose or usage.+// Flag to indicate that this is an RSC bundle, used to distinguish between client and RSC bundles ReactOnRails.isRSCBundle = true;
node_package/src/registerServerComponent/server.rsc.ts (1)
1-25
: Clean implementation for RSC component registrationThe function provides a clear interface for registering React Server Components directly without additional wrapping, with good documentation explaining how this differs from server bundle registration.
One improvement would be to add explicit return type annotation for the function to enhance API clarity:
-const registerServerComponent = (components: { [id: string]: ReactComponent | RenderFunction }) => +const registerServerComponent = (components: { [id: string]: ReactComponent | RenderFunction }): void => ReactOnRails.register(components);node_package/src/loadJsonFile.ts (1)
1-25
: Efficient JSON file loading implementation with proper cachingThe implementation correctly resolves paths, handles errors, and caches results to avoid redundant file operations. The comments clearly explain the file location assumptions.
Consider adding these improvements:
- Add more specific error typing for better error handling:
- } catch (error) { + } catch (error: unknown) {
- Consider adding an option to bypass the cache in development environments for easier debugging:
-export default async function loadJsonFile(fileName: string) { +export default async function loadJsonFile(fileName: string, options?: { bypassCache?: boolean }) { // Asset JSON files are uploaded to node renderer. // Renderer copies assets to the same place as the server-bundle.js and rsc-bundle.js. // Thus, the __dirname of this code is where we can find the manifest file. const filePath = path.resolve(__dirname, fileName); const loadedJsonFile = loadedJsonFiles.get(filePath); - if (loadedJsonFile) { + if (loadedJsonFile && !options?.bypassCache) { return loadedJsonFile; }node_package/src/registerServerComponent/client.tsx (1)
40-46
: Clean implementation of the new RSC rendering approachThe refactored implementation properly wraps each component with the
WrapServerComponentRenderer
andRSCRoute
components, maintaining the same function signature for backward compatibility.While the JSDoc comment (lines 7-38) explains the purpose and usage of this function, it should be updated to reflect the new implementation that uses
RSCRoute
instead ofRSCClientRoot
./** * Registers React Server Components (RSC) with React on Rails. - * This function wraps server components with RSCClientRoot to handle client-side rendering + * This function wraps server components with RSCRoute through WrapServerComponentRenderer to handle client-side rendering * and hydration of RSC payloads. *node_package/src/RSCServerRoot.tsx (1)
27-29
: Consider memoizing the root element.Since this is a server-side component, memoization could improve performance when the same component is rendered multiple times.
const root = <RSCProvider>{suspensableServerComponent}</RSCProvider>; - return () => root; + // Memoize the render function to avoid recreating the element on each render + const renderFunction = React.useMemo(() => () => root, [root]); + return renderFunction;lib/react_on_rails/helper.rb (1)
654-655
: Improve boolean condition syntax and add render request IDThe data-render-request-id attribute is important for tracking RSC render requests, but there's a style improvement opportunity in the boolean condition.
- "data-force-load" => (render_options.force_load ? true : nil), + "data-force-load" => render_options.force_load || nil, "data-render-request-id" => render_options.render_request_id)🧰 Tools
🪛 RuboCop (1.73)
[convention] 654-654: Use double pipes
||
instead.(Style/RedundantCondition)
node_package/src/wrapServerComponentRenderer/server.tsx (2)
12-15
: Fix error message to reference correct componentThe error message incorrectly references "RSCClientRoot" instead of "WrapServerComponentRenderer".
const wrapper: RenderFunction = async (props, railsContext) => { if (!railsContext) { - throw new Error('RSCClientRoot: No railsContext provided'); + throw new Error('WrapServerComponentRenderer: No railsContext provided'); }
30-34
: Consider a more meaningful fallback for SuspenseUsing
null
as a fallback might lead to layout shifts when the component is loading.const suspensableServerComponent = ( - <React.Suspense fallback={null}> + <React.Suspense fallback={<div aria-busy="true" aria-live="polite">Loading...</div>}> <Component {...props} /> </React.Suspense> );node_package/src/RSCClientRoot.tsx (1)
41-45
: Consider a more meaningful fallback for SuspenseUsing
null
as a fallback might lead to layout shifts during loading. A simple loading indicator would improve user experience.const SuspensableRSCRoute = ( - <React.Suspense fallback={null}> + <React.Suspense fallback={<div aria-busy="true" aria-live="polite">Loading...</div>}> <ServerComponentContainer /> </React.Suspense> );node_package/src/RSCProvider.tsx (2)
23-30
: Stable cache keys – JSON.stringify is brittle
JSON.stringify
orders object keys alphabetically only by chance; two prop objects with identical content but different key order serialise differently, producing duplicate cache entries.Consider hashing after a stable-stringify (e.g.
fast-json-stable-stringify
) or a deep-hash function.
50-53
: Memoise context value to prevent needless re-rendersBecause a new object literal is passed on every render, all descendants that call
useContext
will re-render even when nothing changed.-return <RSCContext.Provider value={{ getCachedComponent, getComponent }}>{children}</RSCContext.Provider>; +const ctxValue = React.useMemo( + () => ({ getCachedComponent, getComponent }), + [], // safe because both functions are stable +); +return <RSCContext.Provider value={ctxValue}>{children}</RSCContext.Provider>;node_package/src/streamServerRenderedReactComponent.ts (1)
187-190
: Compile-time guarantee forrailsContext
A runtime guard is good, but adding
railsContext: RailsContext
to theRenderParams
type would catch violations earlier and eliminate a potential unchecked cast downstream.If changing the public type isn’t feasible, at least mark the property as required in the internal variant used by streaming helpers.
node_package/src/getReactServerComponent.client.ts (1)
80-85
: Cache-key may explode for large propsUsing
JSON.stringify
directly in the cache key can create multi-KB keys and is brittle with respect to property-order differences.
Consider hashing the props (e.g. SHA-256) or serialising with a stable stringifier to keep the key short and collision-safe.node_package/src/wrapServerComponentRenderer/client.tsx (3)
25-28
: Error message references outdated componentThe thrown error still mentions “RSCClientRoot”, which was removed. Update for clarity:
- throw new Error('RSCClientRoot: No railsContext provided'); + throw new Error('WrapServerComponentRenderer: No railsContext provided');
42-48
: DOM-node validation message is staleSame naming issue as above and the second message omits the wrapper’s name:
- throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`); + throw new Error( + `WrapServerComponentRenderer: No DOM node found for id "${domNodeId}"`, + );
54-57
: Return type workaround leaks into production codeReturning an empty string merely to satisfy the
RenderFunction
contract is fragile and may mislead future maintainers.
Prefer widening theRenderFunction
return type tovoid | string
(or similar) and returningundefined
here.docs/guides/configuration.md (1)
107-131
: Cross-reference prerequisite flag and add initializer snippetDocs mention
config.enable_rsc_support = true
, but no example is shown. A short snippet prevents mis-configuration:# In `config/initializers/react_on_rails_pro.rb` ReactOnRailsPro.configure do |config| # Enable React Server Components support config.enable_rsc_support = true endAlso consider linking to the section that explains how to generate the three manifest files to give newcomers the full picture.
node_package/src/injectRSCPayload.ts (1)
17-22
: Escape only known dangerous sequences – consider</script>
split edge
escapeScript
handles<!--
and</script>
, but a payload chunk could contain
</scr
+ipt>
split across two pushes, bypassing the regex.
While unlikely, using a robust HTML-escaping lib (or Base64-encoding the payload and decoding in JS)
removes this class of issues altogether.node_package/src/getReactServerComponent.server.ts (1)
66-70
: Typo in error message can mislead users.The thrown text references
serverClientManifestFileName
, while the actual property name isreactServerClientManifestFileName
.-'serverClientManifestFileName and reactServerClientManifestFileName are required. ' + +'reactClientManifestFileName and reactServerClientManifestFileName are required. ' +node_package/src/types/index.ts (1)
431-433
: Duplicate JSDoc opener introduces syntax noise.There are two consecutive
/**
lines, which may confuse TS-server and linters.- /** - /** + /**
🛑 Comments failed to post (17)
node_package/tests/RSCClientRoot.test.jsx (1)
14-14:
⚠️ Potential issueFix import path inconsistency
The import path has been updated to use the new
.tsx
extension, but there's an inconsistency at line 46 wherejest.requireActual('../src/RSCClientRoot.ts')
still references the old.ts
extension. This could cause test failures.- jest.requireActual('../src/RSCClientRoot.ts'); + jest.requireActual('../src/RSCClientRoot.tsx');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.- jest.requireActual('../src/RSCClientRoot.ts'); + jest.requireActual('../src/RSCClientRoot.tsx');
node_package/src/serverRenderReactComponent.ts (1)
118-118:
⚠️ Potential issueFixed error handling by passing the correct error state.
This change fixes a bug in error handling by passing
errorRenderState
instead ofrenderState
tocreateResultObject
. Now when an error occurs during rendering, the final result object will correctly include the full error details and flags instead of the original render state.This ensures that error information is properly propagated to the client for better debugging and error reporting.
node_package/src/RSCServerRoot.tsx (1)
12-14:
⚠️ Potential issueFix error message to reference the correct component name.
The error message mentions "RSCClientRoot" but this is the "RSCServerRoot" component.
- throw new Error('RSCClientRoot: No railsContext provided'); + throw new Error('RSCServerRoot: No railsContext provided');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.if (!railsContext) { throw new Error('RSCServerRoot: No railsContext provided'); }
node_package/src/RSCRoute.ts (1)
1-18: 💡 Verification agent
🧩 Analysis chain
Clean implementation of the RSC Route component.
The implementation is well-structured and handles both cached and non-cached component scenarios properly. The use of React hooks and the component lifecycle is appropriate for this use case.
However, note that
React.use()
is an experimental feature in React. I recommend ensuring that there's proper error handling if this component is used with React versions that don't support this feature.
🌐 Web query:
What version of React is required for React.use() hook?
💡 Result:
React Version Required for the
React.use()
HookThe
use()
hook is a new addition introduced in React 19. To useReact.use()
, your project must be running React version 19.0.0 or higher[5][6][2].Details
- The
use()
API allows you to read the value of a promise or context directly in your component’s render logic. This hook enables you to write code that "reads" async resources and context more flexibly than previous hooks such asuseContext
and without the boilerplate needed foruseEffect
anduseState
for async data fetching[2][5][6].- This hook can only be used inside components or other hooks, like standard React hooks[2][6].
- Unlike existing hooks,
use()
can be called conditionally or inside loops, not just at the top level of a component[2].Version History
- React Hooks (such as
useState
,useEffect
) were introduced in React 16.8[3][4].- The
use()
hook was first introduced in React 19[5][6]. It is not available in any earlier version of React.Summary Table
Hook Introduced In Required React Version Basic Hooks ( useState
,useEffect
, etc.)React 16.8 16.8+ use()
React 19 19.0.0+ To use the
React.use()
hook, ensure your project uses React 19 or later[5][6][2]. Older versions of React (including 16.x, 17.x, and 18.x) do not support this API.Citations:
- 1: https://legacy.reactjs.org/docs/hooks-rules.html
- 2: https://react.dev/reference/react/use
- 3: https://legacy.reactjs.org/docs/hooks-intro.html
- 4: https://www.w3schools.com/react/react_hooks.asp
- 5: https://dev.to/vishnusatheesh/react-19-hooks-explained-everything-you-need-to-know-4il6
- 6: https://react.dev/blog/2024/12/05/react-19
- 7: https://www.youtube.com/watch?v=oMvW3A_IRsY
- 8: https://www.npmjs.com/package/react-use
- 9: https://www.react-hook-form.com/advanced-usage/
- 10: https://blixtdev.com/all-about-reacts-new-use-hook/
Ensure React 19+ Compatibility for React.use Hook
The
React.use()
hook was officially added in React 19.0.0, so this component will fail on earlier React versions. Please update your compatibility requirements and/or add a fallback:• Update
package.json
peerDependencies to"react": ">=19.0.0"
• Or wrap the call toReact.use()
in a version check and fall back touseEffect
/useState
for async loading on older React versions
• Document in your README that this component requires React 19+node_package/src/RSCProvider.tsx (2)
2-3:
⚠️ Potential issueRemove
.ts
extensions from import specifiersIncluding the source–file extension (
.ts
) in a TypeScript import causestsc
to emit the same extension to the compiled JavaScript, which then fails at runtime (Cannot find module './types/index.ts'
).
Simply omit the extension; the compiler will resolve the module and transform the emitted JS import to"./types/index.js"
(or whatever is appropriate for your build).-import { RailsContext } from './types/index.ts'; -import { getReactServerComponent } from './getReactServerComponent.client.ts'; +import { RailsContext } from './types'; +import { getReactServerComponent } from './getReactServerComponent.client';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.import { RailsContext } from './types'; import { getReactServerComponent } from './getReactServerComponent.client';
5-9: 🛠️ Refactor suggestion
Allow
getCachedComponent
to returnundefined
getCachedComponent
legitimately returnsundefined
when the cache-misses, but the type signature hides that fact.
Surfacing the possibility avoids unsound narrowing for callers.-type RSCContextType = { - getCachedComponent: (componentName: string, componentProps: unknown) => React.ReactNode; +type RSCContextType = { + getCachedComponent: ( + componentName: string, + componentProps: unknown, + ) => React.ReactNode | undefined;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.type RSCContextType = { getCachedComponent: ( componentName: string, componentProps: unknown, ) => React.ReactNode | undefined; getComponent: (componentName: string, componentProps: unknown) => Promise<React.ReactNode>; };
package.json (1)
68-68: 🛠️ Refactor suggestion
Pin Git dependency to a commit or tag
Depending on a moving branch hampers reproducibility: every install can yield a different tree, breaking deterministic builds and cacheability.
Prefer:
"react-on-rails-rsc": "github:shakacode/react_on_rails_rsc#<commit-sha>"or wait until the branch is merged and published.
node_package/src/streamServerRenderedReactComponent.ts (2)
11-13:
⚠️ Potential issueStrip
.ts
extension from importSame issue as earlier: keep imports extension-less so the compiled JS can resolve.
-import injectRSCPayload from './injectRSCPayload.ts'; +import injectRSCPayload from './injectRSCPayload';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.import type { RenderParams, StreamRenderState, StreamableComponentResult } from './types/index.ts'; import injectRSCPayload from './injectRSCPayload';
91-101: 🛠️ Refactor suggestion
consoleReplayTimeoutId
may be used before assignment
let consoleReplayTimeoutId: NodeJS.Timeout;
isundefined
until the firstsetTimeout
, yetclearTimeout(consoleReplayTimeoutId)
is called immediately.
TypeScript flags this as “variable used before assignment” and, at runtime,clearTimeout(undefined)
is harmless but the type error blocks compilation.-let consoleReplayTimeoutId: NodeJS.Timeout; +let consoleReplayTimeoutId: NodeJS.Timeout | undefined;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.let consoleReplayTimeoutId: NodeJS.Timeout | undefined; const buildConsoleReplayChunk = () => { const consoleReplayScript = buildConsoleReplay(consoleHistory, previouslyReplayedConsoleMessages); previouslyReplayedConsoleMessages = consoleHistory?.length || 0; if (consoleReplayScript.length === 0) { return null; } const consoleReplayJsonChunk = JSON.stringify(createResultObject('', consoleReplayScript, renderState)); return consoleReplayJsonChunk; };
node_package/src/getReactServerComponent.client.ts (2)
19-27:
⚠️ Potential issueAdd basic fetch-error handling before streaming the response
If the Rails endpoint responds with a non-200 status the current logic happily proceeds, resulting in a cryptic “No stream found” or a runtime read error later.
A quickresponse.ok
guard will surface the real problem early.const response = await fetchPromise; - const stream = response.body; + if (!response.ok) { + throw new Error( + `Failed to fetch RSC payload (status ${response.status}): ${await response + .clone() + .text()}`, + ); + } + const stream = response.body;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const createFromFetch = async (fetchPromise: Promise<Response>) => { const response = await fetchPromise; if (!response.ok) { throw new Error( `Failed to fetch RSC payload (status ${response.status}): ${await response .clone() .text()}`, ); } const stream = response.body; if (!stream) { throw new Error('No stream found in response'); } const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream); return createFromReadableStream<React.ReactNode>(transformedStream); };
29-34: 🛠️ Refactor suggestion
⚠️ Potential issuePrevent “/undefined/” URLs and URL-encode the props
rscPayloadGenerationUrl
is optional, so calling.replace
or string-interpolating it can yield
/undefined/<component>
and break the request.
Additionally, raw JSON in the query string is not URL-safe (contains{
,}
,"
, etc.).- const strippedUrlPath = rscPayloadGenerationUrl?.replace(/^\/|\/$/g, ''); - return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`)); + const basePath = (rscPayloadGenerationUrl ?? '').replace(/^\/|\/$/g, ''); + const encodedProps = encodeURIComponent(propsString); + return createFromFetch(fetch(`/${basePath}/${componentName}?props=${encodedProps}`));Consider falling back to a sensible default path or throwing when the URL is missing, depending on expected behaviour.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const fetchRSC = ({ componentName, componentProps, railsContext }: ClientGetReactServerComponentProps) => { const propsString = JSON.stringify(componentProps); const { rscPayloadGenerationUrl } = railsContext; const basePath = (rscPayloadGenerationUrl ?? '').replace(/^\/|\/$/g, ''); const encodedProps = encodeURIComponent(propsString); return createFromFetch(fetch(`/${basePath}/${componentName}?props=${encodedProps}`)); };
node_package/src/injectRSCPayload.ts (1)
46-53:
⚠️ Potential issueAvoid
async
Promise executors (lint error & micro-task confusion)Lint correctly flags the
async
executor. Wrap the async work in an IIFE instead:- new Promise(async (resolve) => { - for await (const chunk of stream ?? []) { - const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk); - writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey); - } - resolve(); - }), + (async () => { + for await (const chunk of stream ?? []) { + const decodedChunk = + typeof chunk === 'string' ? chunk : decoder.decode(chunk); + writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey); + } + })(),This satisfies the same requirement without violating best practices.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.(async () => { for await (const chunk of stream ?? []) { const decodedChunk = typeof chunk === 'string' ? chunk : decoder.decode(chunk); writeChunk(JSON.stringify(decodedChunk), resultStream, cacheKey); } })(),
🧰 Tools
🪛 Biome (1.9.4)
[error] 46-52: Promise executor functions should not be
async
.(lint/suspicious/noAsyncPromiseExecutor)
node_package/src/getReactServerComponent.server.ts (3)
46-48: 🛠️ Refactor suggestion
Avoid hard-coding the public assets prefix.
'/webpack/development/'
will break in production or when the asset host changes.
Surface this as a parameter (env var orrailsContext
) with a sane default.
73-77:
⚠️ Potential issue
ReactOnRails
is used without being in scope – compilation failure risk.
ReactOnRails
is referenced but never imported or declared. Import it (preferred) or reference viaglobalThis
.+import type { ReactOnRailsInternal } from './types/index.ts'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import ReactOnRails from 'react-on-rails';Also cast if you need the internal helpers:
const ROR = ReactOnRails as unknown as ReactOnRailsInternal; if (typeof ROR.getRSCPayloadStream !== 'function') { ... }
30-33:
⚠️ Potential issueFix misspelling & add defensive check when building
moduleMap
.
aboluteFileUrl
is miss-spelled (should beabsoluteFileUrl
).
More importantly, the corresponding entry may be absent inreactServerManifest
, which will raise a runtime error when destructuring. Guard againstundefined
and surface an explicit error.-Object.entries(reactClientManifest).forEach(([aboluteFileUrl, clientFileBundlingInfo]) => { - const { id, chunks } = reactServerManifest[aboluteFileUrl]; +Object.entries(reactClientManifest).forEach(([absoluteFileUrl, clientFileBundlingInfo]) => { + const serverEntry = reactServerManifest[absoluteFileUrl]; + if (!serverEntry) { + throw new Error( + `Missing server-side manifest entry for "${absoluteFileUrl}". ` + + 'Ensure both client & server RSC manifests are generated with identical keys.', + ); + } + const { id, chunks } = serverEntry;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.Object.entries(reactClientManifest).forEach(([absoluteFileUrl, clientFileBundlingInfo]) => { const serverEntry = reactServerManifest[absoluteFileUrl]; if (!serverEntry) { throw new Error( `Missing server-side manifest entry for "${absoluteFileUrl}". ` + 'Ensure both client & server RSC manifests are generated with identical keys.', ); } const { id, chunks } = serverEntry; moduleMap[clientFileBundlingInfo.id] = { '*': {
node_package/src/RSCPayloadGenerator.ts (2)
16-24: 🛠️ Refactor suggestion
Provide unsubscribe handle to avoid callback build-up.
onRSCPayloadGenerated
adds callbacks but never removes them, producing unbounded growth for long-lived processes.export const onRSCPayloadGenerated = (railsContext: RailsContext, callback: RSCPayloadCallback) => { const callbacks = rscPayloadCallbacks.get(railsContext) || []; callbacks.push(callback); rscPayloadCallbacks.set(railsContext, callbacks); // Call callback for any existing streams for this context const existingStreams = mapRailsContextToRSCPayloadStreams.get(railsContext) || []; existingStreams.forEach((streamInfo) => callback(streamInfo)); + + // Return disposer + return () => { + const cbs = rscPayloadCallbacks.get(railsContext) ?? []; + rscPayloadCallbacks.set( + railsContext, + cbs.filter((cb) => cb !== callback), + ); + }; };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.export const onRSCPayloadGenerated = (railsContext: RailsContext, callback: RSCPayloadCallback) => { const callbacks = rscPayloadCallbacks.get(railsContext) || []; callbacks.push(callback); rscPayloadCallbacks.set(railsContext, callbacks); // Call callback for any existing streams for this context const existingStreams = mapRailsContextToRSCPayloadStreams.get(railsContext) || []; existingStreams.forEach((streamInfo) => callback(streamInfo)); // Return disposer return () => { const cbs = rscPayloadCallbacks.get(railsContext) ?? []; rscPayloadCallbacks.set( railsContext, cbs.filter((cb) => cb !== callback), ); }; };
12-15: 🛠️ Refactor suggestion
Potential memory-leak: use
WeakMap
for context keys.
RailsContext
objects are unique per request. Storing them in aMap
prevents GC even after the request completes.
Switch toWeakMap
so entries are released automatically.-const mapRailsContextToRSCPayloadStreams = new Map<RailsContext, RSCPayloadStreamInfo[]>(); -const rscPayloadCallbacks = new Map<RailsContext, Array<RSCPayloadCallback>>(); +const mapRailsContextToRSCPayloadStreams = new WeakMap<RailsContext, RSCPayloadStreamInfo[]>(); +const rscPayloadCallbacks = new WeakMap<RailsContext, Array<RSCPayloadCallback>>();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const mapRailsContextToRSCPayloadStreams = new WeakMap<RailsContext, RSCPayloadStreamInfo[]>(); const rscPayloadCallbacks = new WeakMap<RailsContext, Array<RSCPayloadCallback>>();
…sc function is called to avoid hydration race conditions
…improve code clarity and maintainability
Rendering React Server Components Inside Client Components
This PR introduces a powerful new capability to the React on Rails framework: the ability to render React Server Components (RSC) directly inside React Client Components. This bypasses a fundamental limitation of React's component model while maintaining performance and preventing hydration issues.
📋 Overview
Important
This feature should be used judiciously. It's best suited for server components whose props change very rarely, such as router routes. Do not use this with components whose props change frequently as it triggers HTTP requests to the server on each re-render.
Before this PR
Server Components could only be embedded inside Client Components if passed as a prop from a parent Server Component:
After this PR
Server Components can be directly rendered inside Client Components using the new
RSCRoute
component:🔄 Implementation Details
🔄 RSC Payload Lifecycle
The RSC payload lifecycle follows two distinct paths depending on whether the component is being server-side rendered or client-side rendered.
Server-Side Rendering Flow
Client Component Renders RSCRoute
<RSCRoute componentName="ServerComponent" componentProps={props} />
RSC Payload Generation
RSCRoute
communicates with the RSC bundle viagetRSCPayloadStream
Server-Side HTML Generation
injectRSCPayload
function collects and tracks all RSC payloadsPayload Embedding
Client-Side Rendering Flow
Initial Hydration
RSCRoute
renders during hydration, it checks for embedded payloadsPost-Navigation Rendering
RSCRoute
makes an HTTP request to fetch the RSC payload/${rscPayloadGenerationUrl}/${componentName}?props=${propsString}
Component Rendering
The key innovation in this architecture is how it handles the two rendering scenarios differently:
This dual approach provides the best of both worlds: fast initial page loads with embedded data and dynamic component loading for subsequent interactions.
🧩 Key Components
RSCProvider
At the foundation of this architecture is the
RSCProvider
, which provides the core functionality for fetching and rendering server components.The
RSCProvider
handles the complex logic of:This component is environment-aware and behaves differently when:
While you could use the
useRSC
hook directly in client components, it's generally better to use theRSCRoute
component which provides a simpler interface.wrapServerComponentRenderer
Because the
RSCProvider
has different implementations for client and server environments, you shouldn't create it manually. Instead, thewrapServerComponentRenderer
function handles this complexity:This higher-order function:
RSCProvider
implementationThe wrapped component is ready to be registered with React on Rails and will properly handle server component rendering in any environment.
RSCRoute
Building on the foundation provided by
RSCProvider
andwrapServerComponentRenderer
, theRSCRoute
component provides the simplest way to render server components:RSCRoute
is the primary component you'll use in your application to:It uses React's
use
hook to integrate with Suspense boundaries and provide a consistent loading experience across your application.These three components form a cohesive system that makes server components usable in client components while maintaining performance and preventing hydration mismatches.
🔍 Use Cases and Examples
❌ Bad Example - Frequently Changing Props
Warning
This implementation will make a server request on every state change, significantly impacting performance.
✅ Good Example - Router Integration
🧩 Nested Route Example
The framework supports nesting client and server components to arbitrary depth:
To use
Outlet
in server components, create a client version:Then use in server components:
🔧 Internal Implementation
This section details the inner workings of the React Server Components integration in React on Rails, explaining how server components are generated, transported, and rendered.
getReactServerComponent
At the core of the RSC implementation is the
getReactServerComponent
function, which has environment-specific implementations.Server Implementation
On the server, this function:
ReactOnRails.getRSCPayloadStream
(Explained in RSCPayloadGenerator)The manifest creation process maps module IDs between server and client bundles, allowing React to correctly associate server components with their client counterparts.
Client Implementation
On the client, this function:
window.REACT_ON_RAILS_RSC_PAYLOADS[key]
Both paths ultimately use React's
createFromReadableStream
to convert the RSC payload into React elements.RSCProvider
The
RSCProvider
creates a React context for server component operations. The provider accepts agetServerComponent
function, making it environment-agnostic. It can work with either the server or client implementation ofgetReactServerComponent
:Key aspects of the implementation:
Component Caching: Uses in-memory cache to store already rendered components
Promise Tracking: Prevents duplicate requests for the same component
Cache Key Generation: Creates unique keys based on component name, props, and request ID
wrapServerComponentRenderer
This higher-order function wraps components with RSC capability. It creates the proper RSCProvider for each environment, passing the environment-specific implementation of
getServerComponent
to the provider. On the server, it passes the server implementation ofgetReactServerComponent
, while on the client, it passes the client implementation.Server Implementation
Client Implementation
Key differences:
This function replaces the previous
RSCServerRoot
andRSCClientRoot
components, providing a more flexible approach that works with any client component or render function containing server components. The wrapped component can be either a React component or a render function that returns a React component on the server, or a renderer function on the client.RSCPayloadGenerator
The
RSCPayloadGenerator
coordinates the generation and tracking of RSC payloads:The implementation:
Payload Generation: Calls the global
generateRSCPayload
function to get the RSC streamStream Duplication: Creates duplicate streams to serve different consumers: one will be used to SSR the server component and one will be used to inject the payload into the HTML
Payload Tracking: Stores streams by railsContext for later injection
Callback Notification: Notifies registered callbacks about new streams
These functions are exposed globally via the ReactOnRails object, making them accessible anywhere in the application.
injectRSCPayload
The
injectRSCPayload
function embeds RSC payloads into the HTML stream:Key aspects:
Stream Initialization: Creates a result stream to hold HTML + RSC payloads
RSC Payload Monitoring: Listens for newly generated payloads
Array Initialization: Creates a global array to store payload chunks
Chunk Embedding: Writes each chunk to the result stream as a script tag
HTML Processing: Passes through HTML while ensuring payloads are injected
The timing of these operations is critical for proper hydration:
The entire system ensures that:
This complex orchestration allows server components to be rendered inside client components while maintaining performance and preventing hydration mismatches.
🔄 Preventing Race Conditions During Hydration
A critical aspect of the RSC implementation is preventing race conditions during component hydration. Specifically, we need to ensure that:
The Race Condition: Without careful implementation, client components might be hydrated before their corresponding RSC payload arrays are initialized in the page, causing unnecessary network requests.
How We Prevent This Race Condition
Our approach relies on a fundamental behavior of React's hydration process (You can check it out in
SuspenseHydration.test.tsx
and at the/async_on_server_sync_on_client
page in the RORP dummy app):React's Hydration Sequence: Client components only hydrate after their HTML content has been streamed to the client and is present in the DOM.
Early Payload Initialization: When a server component is rendered during SSR,
getReactServerComponent
callsReactOnRails.getRSCPayloadStream
which:Parallel Streaming: The HTML stream processes payload chunks and component HTML in parallel after initialization of the payload array:
Why This Works
This implementation guarantees that:
The global
REACT_ON_RAILS_RSC_PAYLOADS
object and the component-specific array are initialized before the component's HTML appears in the documentWhen React begins hydration of the component, it triggers
getReactServerComponent
, which finds the pre-initialized array and uses it instead of making an HTTP requestEven in cases where the full payload hasn't finished streaming, the client code can read from the array as new chunks arrive, ensuring progressive rendering
If during component hydration, no RSC payload array is found in the global
REACT_ON_RAILS_RSC_PAYLOADS
object, it indicates that the component was not server-side rendered. In this case,getReactServerComponent
falls back to making an HTTP request to fetch the RSC payload. This scenario occurs in two common cases:showChild
becomes true,MyServerComponent
needs to be fetched via HTTP since it wasn't part of the initial SSR.This approach has been verified through tests like
SuspenseHydration.test.tsx
which confirm that React's hydration behavior consistently follows this pattern, effectively preventing the race condition.📝 Registration Process
Here's a streamlined guide to integrating server components within client components:
1. Register the server component in your RSC bundle
First, register any server components you'll need to reference:
2. Create your client component with RSCRoute
Build a client component that references server components using RSCRoute:
3. Create environment-specific entry points
Client entry point:
Server entry point:
4. Use in your Rails view
Tip
With
auto_load_bundle: true
in your configuration, you can skip the manualReactOnRails.register
andregisterServerComponent
calls, but you still need to create both client and server wrappers with the appropriatewrapServerComponentRenderer
imports.Pull Request checklist
Remove this line after checking all the items here. If the item is not applicable to the PR, both check it out and wrap it by
~
.Add the CHANGELOG entry at the top of the file.
Other Information
Remove this paragraph and mention any other important and relevant information such as benchmarks.
This change is
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests
Chores