Skip to content

Commit 1dccfe1

Browse files
keep html in final render result as object instead of stringifying it
1 parent ffc7653 commit 1dccfe1

8 files changed

+79
-34
lines changed

node_package/src/createReactOutput.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,11 @@ export default function createReactOutput({
7777
// We just return at this point, because calling function knows how to handle this case and
7878
// we can't call React.createElement with this type of Object.
7979
return renderFunctionResult.then((result) => {
80-
if (typeof result === 'string') {
81-
return result;
82-
}
8380
// If the result is a function, then it returned a React Component (even class components are functions).
8481
if (typeof result === 'function') {
8582
return createReactElementFromRenderFunctionResult(result, name, props);
8683
}
87-
return JSON.stringify(result);
84+
return result;
8885
});
8986
}
9087

node_package/src/isServerRenderResult.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { CreateReactOutputResult, ServerRenderResult, RenderFunctionResult } from './types/index';
1+
import type {
2+
CreateReactOutputResult,
3+
ServerRenderResult,
4+
RenderFunctionResult,
5+
RenderStateHtml,
6+
} from './types/index';
27

38
export function isServerRenderHash(
49
testValue: CreateReactOutputResult | RenderFunctionResult,
@@ -12,7 +17,7 @@ export function isServerRenderHash(
1217
}
1318

1419
export function isPromise<T>(
15-
testValue: CreateReactOutputResult | RenderFunctionResult | Promise<T> | string | null,
20+
testValue: CreateReactOutputResult | RenderFunctionResult | Promise<T> | RenderStateHtml | string | null,
1621
): testValue is Promise<T> {
1722
return !!(testValue as Promise<T> | null)?.then;
1823
}

node_package/src/serverRenderReactComponent.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as React from 'react';
12
import * as ReactDOMServer from 'react-dom/server';
23
import type { ReactElement } from 'react';
34

@@ -14,6 +15,8 @@ import type {
1415
RenderState,
1516
RenderOptions,
1617
ServerRenderResult,
18+
CreateReactOutputAsyncResult,
19+
RenderStateHtml,
1720
} from './types';
1821

1922
function processServerRenderHash(result: ServerRenderResult, options: RenderOptions): RenderState {
@@ -24,7 +27,7 @@ function processServerRenderHash(result: ServerRenderResult, options: RenderOpti
2427
console.error(`React Router ERROR: ${JSON.stringify(routeError)}`);
2528
}
2629

27-
let htmlResult: string;
30+
let htmlResult;
2831
if (redirectLocation) {
2932
if (options.trace) {
3033
const redirectPath = redirectLocation.pathname + redirectLocation.search;
@@ -35,13 +38,11 @@ function processServerRenderHash(result: ServerRenderResult, options: RenderOpti
3538
// For redirects on server rendering, we can't stop Rails from returning the same result.
3639
// Possibly, someday, we could have the Rails server redirect.
3740
htmlResult = '';
38-
} else if (typeof result.renderedHtml === 'string') {
39-
htmlResult = result.renderedHtml;
4041
} else {
41-
htmlResult = JSON.stringify(result.renderedHtml);
42+
htmlResult = result.renderedHtml;
4243
}
4344

44-
return { result: htmlResult, hasErrors };
45+
return { result: htmlResult ?? null, hasErrors };
4546
}
4647

4748
function processReactElement(result: ReactElement): string {
@@ -56,9 +57,9 @@ as a renderFunction and not a simple React Function Component.`);
5657
}
5758

5859
function processPromise(
59-
result: Promise<string | ReactElement>,
60+
result: CreateReactOutputAsyncResult,
6061
renderingReturnsPromises: boolean,
61-
): Promise<string> | string {
62+
): RenderStateHtml {
6263
if (!renderingReturnsPromises) {
6364
console.error(
6465
'Your render function returned a Promise, which is only supported by a node renderer, not ExecJS.',
@@ -68,10 +69,10 @@ function processPromise(
6869
return '{}';
6970
}
7071
return result.then((promiseResult) => {
71-
if (typeof promiseResult === 'string') {
72-
return promiseResult;
72+
if (React.isValidElement(promiseResult)) {
73+
return processReactElement(promiseResult);
7374
}
74-
return processReactElement(promiseResult);
75+
return promiseResult;
7576
});
7677
}
7778

@@ -98,7 +99,7 @@ function handleRenderingError(e: unknown, options: { componentName: string; thro
9899
}
99100

100101
async function createPromiseResult(
101-
renderState: RenderState & { result: Promise<string> },
102+
renderState: RenderState,
102103
componentName: string,
103104
throwJsErrors: boolean,
104105
): Promise<RenderResult> {

node_package/src/serverRenderUtils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import type { RegisteredComponent, RenderResult, RenderState, StreamRenderState } from './types';
1+
import type {
2+
RegisteredComponent,
3+
RenderResult,
4+
RenderState,
5+
StreamRenderState,
6+
FinalHtmlResult,
7+
} from './types';
28

39
export function createResultObject(
4-
html: string | null,
10+
html: FinalHtmlResult | null,
511
consoleReplayScript: string,
612
renderState: RenderState | StreamRenderState,
713
): RenderResult {

node_package/src/streamServerRenderedReactComponent.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import * as React from 'react';
12
import * as ReactDOMServer from 'react-dom/server';
23
import { PassThrough, Readable } from 'stream';
34
import type { ReactElement } from 'react';
45

56
import ComponentRegistry from './ComponentRegistry';
67
import createReactOutput from './createReactOutput';
7-
import { isServerRenderHash } from './isServerRenderResult';
8+
import { isPromise, isServerRenderHash } from './isServerRenderResult';
89
import buildConsoleReplay from './buildConsoleReplay';
910
import handleError from './handleError';
1011
import { createResultObject, convertToError, validateComponent } from './serverRenderUtils';
@@ -211,7 +212,21 @@ export const streamServerRenderedComponent = <T, P extends RenderParams>(
211212
});
212213

213214
if (isServerRenderHash(reactRenderingResult)) {
214-
throw new Error('Server rendering of streams is not supported for server render hashes or promises.');
215+
throw new Error('Server rendering of streams is not supported for server render hashes.');
216+
}
217+
218+
if (isPromise(reactRenderingResult)) {
219+
const promiseAfterRejectingHash = reactRenderingResult.then((result) => {
220+
if (!React.isValidElement(result)) {
221+
throw new Error(
222+
`Invalid React element detected while rendering ${componentName}. If you are trying to stream a component registered as a render function, ` +
223+
`please ensure that the render function returns a valid React component, not a server render hash. ` +
224+
`This error typically occurs when the render function does not return a React element or returns an incorrect type.`,
225+
);
226+
}
227+
return result;
228+
});
229+
return renderStrategy(promiseAfterRejectingHash, options);
215230
}
216231

217232
return renderStrategy(reactRenderingResult, options);

node_package/src/transformRSCStreamAndReplayConsoleLogs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RenderResult } from './types';
1+
import { RSCPayloadChunk } from './types';
22

33
export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableStream<Uint8Array>) {
44
return new ReadableStream({
@@ -18,7 +18,7 @@ export default function transformRSCStreamAndReplayConsoleLogs(stream: ReadableS
1818
.filter((line) => line.trim() !== '')
1919
.map((line) => {
2020
try {
21-
return JSON.parse(line) as RenderResult;
21+
return JSON.parse(line) as RSCPayloadChunk;
2222
} catch (error) {
2323
console.error('Error parsing JSON:', line, error);
2424
throw error;

node_package/src/types/index.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,17 @@ interface ServerRenderResult {
5454
error?: Error;
5555
}
5656

57-
type CreateReactOutputResult = ServerRenderResult | ReactElement | Promise<string | ReactElement>;
57+
type CreateReactOutputSyncResult = ServerRenderResult | ReactElement<unknown>;
5858

59-
type RenderFunctionResult =
60-
| ReactComponent
61-
| ServerRenderResult
62-
| Promise<string | ServerRenderHashRenderedHtml | ReactComponent>;
59+
type CreateReactOutputAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactElement<unknown>>;
60+
61+
type CreateReactOutputResult = CreateReactOutputSyncResult | CreateReactOutputAsyncResult;
62+
63+
type RenderFunctionSyncResult = ReactComponent | ServerRenderResult;
64+
65+
type RenderFunctionAsyncResult = Promise<string | ServerRenderHashRenderedHtml | ReactComponent>;
66+
67+
type RenderFunctionResult = RenderFunctionSyncResult | RenderFunctionAsyncResult;
6368

6469
/**
6570
* Render functions are used to create dynamic React components or server-rendered HTML with side effects.
@@ -104,6 +109,11 @@ export type {
104109
StoreGenerator,
105110
CreateReactOutputResult,
106111
ServerRenderResult,
112+
ServerRenderHashRenderedHtml,
113+
CreateReactOutputSyncResult,
114+
CreateReactOutputAsyncResult,
115+
RenderFunctionSyncResult,
116+
RenderFunctionAsyncResult,
107117
};
108118

109119
export interface RegisteredComponent {
@@ -160,14 +170,20 @@ export interface ErrorOptions {
160170

161171
export type RenderingError = Pick<Error, 'message' | 'stack'>;
162172

173+
export type FinalHtmlResult = string | ServerRenderHashRenderedHtml;
174+
163175
export interface RenderResult {
164-
html: string | null;
176+
html: FinalHtmlResult | null;
165177
consoleReplayScript: string;
166178
hasErrors: boolean;
167179
renderingError?: RenderingError;
168180
isShellReady?: boolean;
169181
}
170182

183+
export interface RSCPayloadChunk extends RenderResult {
184+
html: string;
185+
}
186+
171187
// from react-dom 18
172188
export interface Root {
173189
render(children: ReactNode): void;
@@ -211,8 +227,10 @@ export interface ReactOnRails {
211227
options: Record<string, string | number | boolean>;
212228
}
213229

230+
export type RenderStateHtml = FinalHtmlResult | Promise<FinalHtmlResult>;
231+
214232
export type RenderState = {
215-
result: null | string | Promise<string>;
233+
result: null | RenderStateHtml;
216234
hasErrors: boolean;
217235
error?: RenderingError;
218236
};

node_package/tests/serverRenderReactComponent.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('serverRenderReactComponent', () => {
5252
assertIsString(renderResult);
5353
const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
5454

55+
assertIsString(html);
5556
const result = html && html.indexOf('>HELLO</div>') > 0;
5657
expect(result).toBeTruthy();
5758
expect(hasErrors).toBeFalsy();
@@ -76,6 +77,7 @@ describe('serverRenderReactComponent', () => {
7677
assertIsString(renderResult);
7778
const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
7879

80+
assertIsString(html);
7981
const result = html && html.indexOf('XYZ') > 0 && html.indexOf('Exception in rendering!') > 0;
8082
expect(result).toBeTruthy();
8183
expect(hasErrors).toBeTruthy();
@@ -183,8 +185,8 @@ describe('serverRenderReactComponent', () => {
183185
await expect(renderResult.then((r) => r.hasErrors)).resolves.toBeFalsy();
184186
});
185187

186-
// When an async render function returns an object, serverRenderReactComponent will return the object as it after stringifying it.
187-
// It does not validate properties like renderedHtml or hasErrors; it simply returns the stringified object.
188+
// When an async render function returns an object, serverRenderReactComponent will return the object as it is.
189+
// It does not validate properties like renderedHtml or hasErrors; it simply returns the object.
188190
// This behavior can cause issues with the ruby_on_rails gem.
189191
// To avoid such issues, ensure that the returned object includes a `componentHtml` property and use the `react_component_hash` helper.
190192
// This is demonstrated in the "can render async render function used with react_component_hash helper" test.
@@ -206,7 +208,7 @@ describe('serverRenderReactComponent', () => {
206208
const renderResult = serverRenderReactComponent(renderParams);
207209
assertIsPromise(renderResult);
208210
const html = await renderResult.then((r) => r.html);
209-
expect(html).toEqual(JSON.stringify(resultObject));
211+
expect(html).toMatchObject(resultObject);
210212
});
211213

212214
// Because the object returned by the async render function is returned as it is,
@@ -229,7 +231,7 @@ describe('serverRenderReactComponent', () => {
229231
const renderResult = serverRenderReactComponent(renderParams);
230232
assertIsPromise(renderResult);
231233
const result = await renderResult;
232-
expect(result.html).toEqual(JSON.stringify(reactComponentHashResult));
234+
expect(result.html).toMatchObject(reactComponentHashResult);
233235
});
234236

235237
it('serverRenderReactComponent renders async render function that returns react component', async () => {
@@ -270,6 +272,7 @@ describe('serverRenderReactComponent', () => {
270272
assertIsString(renderResult);
271273
const { html, hasErrors }: RenderResult = JSON.parse(renderResult) as RenderResult;
272274

275+
assertIsString(html);
273276
const result = html && html.indexOf('renderer') > 0 && html.indexOf('Exception in rendering!') > 0;
274277
expect(result).toBeTruthy();
275278
expect(hasErrors).toBeTruthy();

0 commit comments

Comments
 (0)