Skip to content

Commit 1f932bb

Browse files
Initial Implementation of RSC
Signed-off-by: Khaled Emara <[email protected]>
1 parent fea6536 commit 1f932bb

File tree

9 files changed

+918
-60
lines changed

9 files changed

+918
-60
lines changed

node_package/src/RSCRoute.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
import { Route } from 'react-router-dom';
3+
4+
import useRSC from './useRSC';
5+
6+
import type { RSCRouteProps } from './types/index';
7+
8+
export default function RSCRoute({ path, componentName, props }: RSCRouteProps): JSX.Element {
9+
const content = useRSC(componentName, props);
10+
return <Route path={path} render={() => content} />;
11+
}

node_package/src/ReactOnRails.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ import type {
1818
ReactComponentOrRenderFunction,
1919
AuthenticityHeaders,
2020
StoreGenerator,
21+
RSCRouteProps,
2122
} from './types';
2223
import reactHydrateOrRender from './reactHydrateOrRender';
24+
import useRSC from './useRSC';
25+
import RSCRoute from './RSCRoute';
2326

2427
/* eslint-disable @typescript-eslint/no-explicit-any */
2528
type Store = any;
@@ -33,9 +36,9 @@ if (ctx === undefined) {
3336
if (ctx.ReactOnRails !== undefined) {
3437
throw new Error(`
3538
The ReactOnRails value exists in the ${ctx} scope, it may not be safe to overwrite it.
36-
39+
3740
This could be caused by setting Webpack's optimization.runtimeChunk to "true" or "multiple," rather than "single." Check your Webpack configuration.
38-
41+
3942
Read more at https://github.com/shakacode/react_on_rails/issues/1558.
4043
`);
4144
}
@@ -101,7 +104,7 @@ ctx.ReactOnRails = {
101104
* `traceTurbolinks: true|false Gives you debugging messages on Turbolinks events
102105
* `turbo: true|false Turbo (the follower of Turbolinks) events will be registered, if set to true.
103106
*/
104-
setOptions(newOptions: {traceTurbolinks?: boolean, turbo?: boolean }): void {
107+
setOptions(newOptions: { traceTurbolinks?: boolean, turbo?: boolean }): void {
105108
if (typeof newOptions.traceTurbolinks !== 'undefined') {
106109
this.options.traceTurbolinks = newOptions.traceTurbolinks;
107110

@@ -241,6 +244,16 @@ ctx.ReactOnRails = {
241244
return serverRenderReactComponent(options);
242245
},
243246

247+
/**
248+
* Used for React Server Components by Rails
249+
* @param options
250+
*/
251+
renderReactServerComponent(options: RenderParams): null | string | Promise<RenderResult> {
252+
throw new Error(
253+
'renderReactServerComponent() can only be called on the server side.',
254+
);
255+
},
256+
244257
/**
245258
* Used by Rails to catch errors in rendering
246259
* @param options
@@ -283,6 +296,14 @@ ctx.ReactOnRails = {
283296
resetOptions(): void {
284297
this.options = Object.assign({}, DEFAULT_OPTIONS);
285298
},
299+
300+
useRSC(componentName: string, props?: Record<string, unknown>): string | null {
301+
return useRSC(componentName, props);
302+
},
303+
304+
RSCRoute(props: RSCRouteProps): JSX.Element {
305+
return RSCRoute(props);
306+
},
286307
};
287308

288309
ctx.ReactOnRails.resetOptions();
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import type { ReactElement } from 'react';
2+
3+
import * as ClientStartup from './clientStartup';
4+
import handleError from './handleError';
5+
import ComponentRegistry from './ComponentRegistry';
6+
import StoreRegistry from './StoreRegistry';
7+
import serverRenderReactComponent from './serverRenderReactComponent';
8+
import renderReactServerComponent from './renderReactServerComponent';
9+
import buildConsoleReplay from './buildConsoleReplay';
10+
import createReactOutput from './createReactOutput';
11+
import Authenticity from './Authenticity';
12+
import context from './context';
13+
import type {
14+
RegisteredComponent,
15+
RenderParams,
16+
RenderResult,
17+
RenderReturnType,
18+
ErrorOptions,
19+
ReactComponentOrRenderFunction,
20+
AuthenticityHeaders,
21+
StoreGenerator,
22+
RSCRouteProps,
23+
} from './types';
24+
import reactHydrateOrRender from './reactHydrateOrRender';
25+
26+
/* eslint-disable @typescript-eslint/no-explicit-any */
27+
type Store = any;
28+
29+
const ctx = context();
30+
31+
if (ctx === undefined) {
32+
throw new Error("The context (usually Window or NodeJS's Global) is undefined.");
33+
}
34+
35+
if (ctx.ReactOnRails !== undefined) {
36+
throw new Error(`
37+
The ReactOnRails value exists in the ${ctx} scope, it may not be safe to overwrite it.
38+
39+
This could be caused by setting Webpack's optimization.runtimeChunk to "true" or "multiple," rather than "single." Check your Webpack configuration.
40+
41+
Read more at https://github.com/shakacode/react_on_rails/issues/1558.
42+
`);
43+
}
44+
45+
const DEFAULT_OPTIONS = {
46+
traceTurbolinks: false,
47+
turbo: false,
48+
};
49+
50+
ctx.ReactOnRails = {
51+
options: {},
52+
/**
53+
* Main entry point to using the react-on-rails npm package. This is how Rails will be able to
54+
* find you components for rendering.
55+
* @param components (key is component name, value is component)
56+
*/
57+
register(components: { [id: string]: ReactComponentOrRenderFunction }): void {
58+
ComponentRegistry.register(components);
59+
},
60+
61+
/**
62+
* Allows registration of store generators to be used by multiple react components on one Rails
63+
* view. store generators are functions that take one arg, props, and return a store. Note that
64+
* the setStore API is different in that it's the actual store hydrated with props.
65+
* @param stores (keys are store names, values are the store generators)
66+
*/
67+
registerStore(stores: { [id: string]: Store }): void {
68+
if (!stores) {
69+
throw new Error('Called ReactOnRails.registerStores with a null or undefined, rather than ' +
70+
'an Object with keys being the store names and the values are the store generators.');
71+
}
72+
73+
StoreRegistry.register(stores);
74+
},
75+
76+
/**
77+
* Allows retrieval of the store by name. This store will be hydrated by any Rails form props.
78+
* Pass optional param throwIfMissing = false if you want to use this call to get back null if the
79+
* store with name is not registered.
80+
* @param name
81+
* @param throwIfMissing Defaults to true. Set to false to have this call return undefined if
82+
* there is no store with the given name.
83+
* @returns Redux Store, possibly hydrated
84+
*/
85+
getStore(name: string, throwIfMissing = true): Store | undefined {
86+
return StoreRegistry.getStore(name, throwIfMissing);
87+
},
88+
89+
/**
90+
* Renders or hydrates the react element passed. In case react version is >=18 will use the new api.
91+
* @param domNode
92+
* @param reactElement
93+
* @param hydrate if true will perform hydration, if false will render
94+
* @returns {Root|ReactComponent|ReactElement|null}
95+
*/
96+
reactHydrateOrRender(domNode: Element, reactElement: ReactElement, hydrate: boolean): RenderReturnType {
97+
return reactHydrateOrRender(domNode, reactElement, hydrate);
98+
},
99+
100+
/**
101+
* Set options for ReactOnRails, typically before you call ReactOnRails.register
102+
* Available Options:
103+
* `traceTurbolinks: true|false Gives you debugging messages on Turbolinks events
104+
* `turbo: true|false Turbo (the follower of Turbolinks) events will be registered, if set to true.
105+
*/
106+
setOptions(newOptions: { traceTurbolinks?: boolean, turbo?: boolean }): void {
107+
if (typeof newOptions.traceTurbolinks !== 'undefined') {
108+
this.options.traceTurbolinks = newOptions.traceTurbolinks;
109+
110+
// eslint-disable-next-line no-param-reassign
111+
delete newOptions.traceTurbolinks;
112+
}
113+
114+
if (typeof newOptions.turbo !== 'undefined') {
115+
this.options.turbo = newOptions.turbo;
116+
117+
// eslint-disable-next-line no-param-reassign
118+
delete newOptions.turbo;
119+
}
120+
121+
if (Object.keys(newOptions).length > 0) {
122+
throw new Error(
123+
`Invalid options passed to ReactOnRails.options: ${JSON.stringify(newOptions)}`,
124+
);
125+
}
126+
},
127+
128+
/**
129+
* Allow directly calling the page loaded script in case the default events that trigger react
130+
* rendering are not sufficient, such as when loading JavaScript asynchronously with TurboLinks:
131+
* More details can be found here:
132+
* https://github.com/shakacode/react_on_rails/blob/master/docs/additional-reading/turbolinks.md
133+
*/
134+
reactOnRailsPageLoaded(): void {
135+
ClientStartup.reactOnRailsPageLoaded();
136+
},
137+
138+
/**
139+
* Returns CSRF authenticity token inserted by Rails csrf_meta_tags
140+
* @returns String or null
141+
*/
142+
143+
authenticityToken(): string | null {
144+
return Authenticity.authenticityToken();
145+
},
146+
147+
/**
148+
* Returns header with csrf authenticity token and XMLHttpRequest
149+
* @param {*} other headers
150+
* @returns {*} header
151+
*/
152+
153+
authenticityHeaders(otherHeaders: { [id: string]: string } = {}): AuthenticityHeaders {
154+
return Authenticity.authenticityHeaders(otherHeaders);
155+
},
156+
157+
// /////////////////////////////////////////////////////////////////////////////
158+
// INTERNALLY USED APIs
159+
// /////////////////////////////////////////////////////////////////////////////
160+
161+
/**
162+
* Retrieve an option by key.
163+
* @param key
164+
* @returns option value
165+
*/
166+
option(key: string): string | number | boolean | undefined {
167+
return this.options[key];
168+
},
169+
170+
/**
171+
* Allows retrieval of the store generator by name. This is used internally by ReactOnRails after
172+
* a rails form loads to prepare stores.
173+
* @param name
174+
* @returns Redux Store generator function
175+
*/
176+
getStoreGenerator(name: string): StoreGenerator {
177+
return StoreRegistry.getStoreGenerator(name);
178+
},
179+
180+
/**
181+
* Allows saving the store populated by Rails form props. Used internally by ReactOnRails.
182+
* @param name
183+
* @returns Redux Store, possibly hydrated
184+
*/
185+
setStore(name: string, store: Store): void {
186+
return StoreRegistry.setStore(name, store);
187+
},
188+
189+
/**
190+
* Clears hydratedStores to avoid accidental usage of wrong store hydrated in previous/parallel
191+
* request.
192+
*/
193+
clearHydratedStores(): void {
194+
StoreRegistry.clearHydratedStores();
195+
},
196+
197+
/**
198+
* @example
199+
* ReactOnRails.render("HelloWorldApp", {name: "Stranger"}, 'app');
200+
*
201+
* Does this:
202+
* ```js
203+
* ReactDOM.render(React.createElement(HelloWorldApp, {name: "Stranger"}),
204+
* document.getElementById('app'))
205+
* ```
206+
* under React 16/17 and
207+
* ```js
208+
* const root = ReactDOMClient.createRoot(document.getElementById('app'))
209+
* root.render(React.createElement(HelloWorldApp, {name: "Stranger"}))
210+
* return root
211+
* ```
212+
* under React 18+.
213+
*
214+
* @param name Name of your registered component
215+
* @param props Props to pass to your component
216+
* @param domNodeId
217+
* @param hydrate Pass truthy to update server rendered html. Default is falsy
218+
* @returns {Root|ReactComponent|ReactElement} Under React 18+: the created React root
219+
* (see "What is a root?" in https://github.com/reactwg/react-18/discussions/5).
220+
* Under React 16/17: Reference to your component's backing instance or `null` for stateless components.
221+
*/
222+
render(name: string, props: Record<string, string>, domNodeId: string, hydrate: boolean): RenderReturnType {
223+
const componentObj = ComponentRegistry.get(name);
224+
const reactElement = createReactOutput({ componentObj, props, domNodeId });
225+
226+
return reactHydrateOrRender(document.getElementById(domNodeId) as Element, reactElement as ReactElement, hydrate);
227+
},
228+
229+
/**
230+
* Get the component that you registered
231+
* @param name
232+
* @returns {name, component, renderFunction, isRenderer}
233+
*/
234+
getComponent(name: string): RegisteredComponent {
235+
return ComponentRegistry.get(name);
236+
},
237+
238+
/**
239+
* Used by server rendering by Rails
240+
* @param options
241+
*/
242+
serverRenderReactComponent(options: RenderParams): null | string | Promise<RenderResult> {
243+
return serverRenderReactComponent(options);
244+
},
245+
246+
/**
247+
* Used for React Server Components by Rails
248+
* @param options
249+
*/
250+
renderReactServerComponent(options: RenderParams): null | string | Promise<RenderResult> {
251+
return renderReactServerComponent(options);
252+
},
253+
254+
/**
255+
* Used by Rails to catch errors in rendering
256+
* @param options
257+
*/
258+
handleError(options: ErrorOptions): string | undefined {
259+
return handleError(options);
260+
},
261+
262+
/**
263+
* Used by Rails server rendering to replay console messages.
264+
*/
265+
buildConsoleReplay(): string {
266+
return buildConsoleReplay();
267+
},
268+
269+
/**
270+
* Get an Object containing all registered components. Useful for debugging.
271+
* @returns {*}
272+
*/
273+
registeredComponents(): Map<string, RegisteredComponent> {
274+
return ComponentRegistry.components();
275+
},
276+
277+
/**
278+
* Get an Object containing all registered store generators. Useful for debugging.
279+
* @returns {*}
280+
*/
281+
storeGenerators(): Map<string, StoreGenerator> {
282+
return StoreRegistry.storeGenerators();
283+
},
284+
285+
/**
286+
* Get an Object containing all hydrated stores. Useful for debugging.
287+
* @returns {*}
288+
*/
289+
stores(): Map<string, Store> {
290+
return StoreRegistry.stores();
291+
},
292+
293+
resetOptions(): void {
294+
this.options = Object.assign({}, DEFAULT_OPTIONS);
295+
},
296+
297+
useRSC(_componentName: string, _props?: Record<string, unknown>): string | null {
298+
throw new Error(
299+
'useRSC() can only be called on the client side.',
300+
);
301+
},
302+
303+
RSCRoute(props: RSCRouteProps): JSX.Element {
304+
throw new Error(
305+
'RSCRoute() can only be called on the client side.',
306+
);
307+
},
308+
};
309+
310+
ctx.ReactOnRails.resetOptions();
311+
312+
ClientStartup.clientStartup(ctx);
313+
314+
export default ctx.ReactOnRails;

0 commit comments

Comments
 (0)