Skip to content

Commit 211257c

Browse files
authored
Harden renderer teardown polish
Closes #3647 ## Summary - Centralize `isRendererTeardownResult` in `packages/react-on-rails/src/rendererTeardown.ts` and expose it as `react-on-rails/@internal/rendererTeardown`, so OSS and Pro share one teardown-result guard instead of carrying inline copies. - Keep the Pro `wrapServerComponentRenderer/client` runtime guard so teardown-shaped server-component render results fail with a clear error before the generic component-shape check. - Harden and clarify the renderer teardown tests around same-id async races, DOM descriptor reuse, and Pro wrapper validation cleanup. - Keep Pro parity coverage for non-native thenable teardown rejection handling. ## Tests - `pnpm install --frozen-lockfile` - `pnpm exec prettier --check packages/react-on-rails/src/rendererTeardown.ts packages/react-on-rails/src/ClientRenderer.ts packages/react-on-rails/src/createReactOutput.ts packages/react-on-rails-pro/src/ClientSideRenderer.ts packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx packages/react-on-rails/tests/ClientRenderer.test.ts packages/react-on-rails/package.json` - `pnpm exec eslint --no-warn-ignored packages/react-on-rails/src/rendererTeardown.ts packages/react-on-rails/src/ClientRenderer.ts packages/react-on-rails/src/createReactOutput.ts packages/react-on-rails-pro/src/ClientSideRenderer.ts packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx packages/react-on-rails/tests/ClientRenderer.test.ts` - `pnpm --filter react-on-rails run type-check` - `pnpm --filter react-on-rails-pro run type-check` - `pnpm --filter react-on-rails exec jest tests/ClientRenderer.test.ts --runInBand` - `pnpm --filter react-on-rails-pro exec jest tests/ClientSideRenderer.test.ts tests/wrapServerComponentRenderer.client.test.jsx --runInBand` - `pnpm --filter react-on-rails run build` - `pnpm --filter react-on-rails-pro run build` - Follow-up focused reruns after review-comment cleanup: `pnpm exec prettier --check packages/react-on-rails/tests/ClientRenderer.test.ts packages/react-on-rails-pro/tests/wrapServerComponentRenderer.client.test.jsx`, `pnpm exec eslint --no-warn-ignored packages/react-on-rails/tests/ClientRenderer.test.ts packages/react-on-rails-pro/tests/wrapServerComponentRenderer.client.test.jsx`, `pnpm --filter react-on-rails exec jest tests/ClientRenderer.test.ts --runInBand`, `pnpm --filter react-on-rails-pro exec jest tests/wrapServerComponentRenderer.client.test.jsx --runInBand`. ## Design Calls - Added an internal OSS export instead of documenting three synchronized `isRendererTeardownResult` copies; Pro already consumes OSS internal subpaths, and this keeps the guard shape single-sourced. - Kept `invokeRendererTeardown` duplicated; that helper still has package-specific runtime/logging context and remains covered by parity tests. - Kept public `RendererFunction` return typing unchanged pending downstream migration data. - Left docs and dummy-app renderer conversions out of this PR per scope; this PR stays focused on renderer teardown diagnostics, guard reuse, and targeted tests. - No changelog entry: this is Pro/OSS renderer hardening plus an invalid-shape diagnostic guard, not a new user-facing feature or documented behavior change. Labels: none. This is a focused test/diagnostic hardening PR with no CI workflow, dependency, generator, SSR output, or broad Pro/core boundary changes. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Internal guard deduplication and stricter runtime validation for invalid server-component render shapes; behavior changes are limited to clearer errors and test-documented teardown edge cases, with no auth or data-path changes. > > **Overview** > **Centralizes** the `isRendererTeardownResult` type guard in `rendererTeardown.ts` and exposes it as `react-on-rails/@internal/rendererTeardown`, replacing inline copies in OSS (`ClientRenderer`, `createReactOutput`) and Pro (`ClientSideRenderer`, `wrapServerComponentRenderer/client`). > > **Pro RSC wrapper** now rejects server-component render functions that resolve to a teardown object with an explicit error (before DOM/context checks), closing #3647-style misconfiguration diagnostics. > > **Tests** add coverage for non-native thenable teardown rejections (Pro), same-id async teardown races where a stale renderer must not overwrite a newer mount (OSS), and `wrapServerComponentRenderer` validation for missing `domNodeId`, DOM node, `railsContext`, and teardown-shaped returns; webpack manifest test aliases the new internal module. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b92db44. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Added runtime validation that rejects invalid server-rendered results (e.g., teardown objects) with clearer error messages. * Improved handling and logging when asynchronous renderer teardown or replacement fails or races. * Improved validation and error messages for missing mount target or missing context during server-component mounting. * **Tests** * Expanded tests for server component rendering validation edge cases. * Added tests ensuring async renderer teardown/replacement logs correctly for promise/thenable scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 23e8d8f commit 211257c

10 files changed

Lines changed: 216 additions & 172 deletions

File tree

packages/react-on-rails-pro/src/ClientSideRenderer.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ import type {
2020
RegisteredComponent,
2121
RendererFunction,
2222
RendererTeardown,
23-
RendererTeardownResult,
2423
Root,
2524
} from 'react-on-rails/types';
2625

2726
import { getRailsContext, resetRailsContext } from 'react-on-rails/context';
2827
import createReactOutput from 'react-on-rails/createReactOutput';
28+
import { isRendererTeardownResult } from 'react-on-rails/@internal/rendererTeardown';
2929
import { isServerRenderHash } from 'react-on-rails/isServerRenderResult';
3030
import { supportsHydrate, supportsRootApi, unmountComponentAtNode } from 'react-on-rails/reactApis';
3131
import reactHydrateOrRender from 'react-on-rails/reactHydrateOrRender';
@@ -74,14 +74,6 @@ function invokeRendererTeardown(teardown: RendererTeardown | undefined, domNodeI
7474
}
7575
}
7676

77-
function isRendererTeardownResult(value: unknown): value is RendererTeardownResult {
78-
return (
79-
value != null &&
80-
typeof value === 'object' &&
81-
typeof (value as { teardown?: unknown }).teardown === 'function'
82-
);
83-
}
84-
8577
// Result of attempting renderer delegation. Pro awaits the renderer inside delegateToRenderer, so it
8678
// pre-resolves to an optional teardown. The core renderer has its own DelegationResult that instead
8779
// carries the raw (possibly still-pending) RendererResult because it cannot await; the two

packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import * as React from 'react';
2626
import * as ReactDOMClient from 'react-dom/client';
2727
import { ReactComponent, ReactComponentOrRenderFunction, RendererFunction } from 'react-on-rails/types';
2828
import isRenderFunction from 'react-on-rails/isRenderFunction';
29+
import { isRendererTeardownResult } from 'react-on-rails/@internal/rendererTeardown';
2930
import { ensureReactUseAvailable } from 'react-on-rails/reactApis';
3031
import { createRSCProvider } from '../RSCProvider.tsx';
3132
import getReactServerComponent from '../getReactServerComponent.client.ts';
@@ -75,6 +76,16 @@ const wrapServerComponentRenderer = (
7576
? ((await componentOrRenderFunction(props, railsContext, domNodeId)) as ReactComponent)
7677
: componentOrRenderFunction;
7778

79+
// Preserve compatibility with existing 3-arg render functions: they are awaited before domNodeId
80+
// validation; moving that check earlier is a future improvement. Keep this teardown-result guard
81+
// before DOM checks so wrong-shaped render results get the clearer error.
82+
if (isRendererTeardownResult(Component)) {
83+
throw new Error(
84+
`wrapServerComponentRenderer: render function for server component '${componentName}' ` +
85+
'returned a renderer teardown result; expected a React component.',
86+
);
87+
}
88+
7889
if (typeof Component !== 'function') {
7990
throw new Error(`wrapServerComponentRenderer: component '${componentName}' is not a function`);
8091
}

packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,42 @@ describe('ClientSideRenderer', () => {
395395
}
396396
});
397397

398+
it('logs a non-native thenable teardown rejection without requiring .catch', async () => {
399+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
400+
try {
401+
const rejection = new Error('thenable teardown boom');
402+
const teardown = jest.fn(
403+
() =>
404+
({
405+
then(_onFulfilled: () => void, onRejected: (error: Error) => void) {
406+
onRejected(rejection);
407+
},
408+
}) as unknown as Promise<void>,
409+
);
410+
const TestRenderer: RendererFunction = (
411+
_props?: Record<string, unknown>,
412+
_railsContext?: RailsContext,
413+
_domNodeId?: string,
414+
) => ({ teardown });
415+
ComponentRegistry.register({ TestComponent: TestRenderer });
416+
const componentSpec = setupTestComponentDom('dom-id-teardown-thenable-reject');
417+
addRailsContext();
418+
419+
await renderOrHydrateComponent(componentSpec);
420+
unmountAll();
421+
expect(teardown).toHaveBeenCalledTimes(1);
422+
423+
await Promise.resolve(); // lets Promise.resolve(nonNativeThenable) settle
424+
await Promise.resolve(); // lets the .catch() rejection handler run
425+
expect(consoleErrorSpy).toHaveBeenCalledWith(
426+
'Error in renderer teardown for dom node "dom-id-teardown-thenable-reject":',
427+
rejection,
428+
);
429+
} finally {
430+
consoleErrorSpy.mockRestore();
431+
}
432+
});
433+
398434
it('rejects with a render error when an async renderer rejects before returning a teardown', async () => {
399435
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
400436
try {

packages/react-on-rails-pro/tests/wrapServerComponentRenderer.client.manifest.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const runWebpack = () => {
4040
},
4141
resolve: {
4242
alias: {
43+
'react-on-rails/@internal/rendererTeardown$': path.join(reactOnRailsSrcRoot, 'rendererTeardown.ts'),
4344
'react-on-rails/isRenderFunction$': path.join(reactOnRailsSrcRoot, 'isRenderFunction.ts'),
4445
'react-on-rails/reactApis$': path.join(reactOnRailsSrcRoot, 'reactApis.cts'),
4546
'react-on-rails/types$': path.join(reactOnRailsSrcRoot, 'types/index.ts'),

packages/react-on-rails-pro/tests/wrapServerComponentRenderer.client.test.jsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,86 @@ const createBailoutDigestError = () => {
3737
return error;
3838
};
3939

40+
const loadWrappedRendererWithMocks = () => {
41+
jest.resetModules();
42+
43+
const unmount = jest.fn();
44+
const render = jest.fn();
45+
const hydrateRoot = jest.fn(() => ({ unmount }));
46+
const createRoot = jest.fn(() => ({ render, unmount }));
47+
const getReactServerComponent = jest.fn(() => jest.fn());
48+
49+
jest.doMock('react-dom/client', () => ({ createRoot, hydrateRoot }));
50+
jest.doMock('react-on-rails-rsc/client.browser', () => ({}));
51+
jest.doMock('../src/getReactServerComponent.client.ts', () => ({
52+
__esModule: true,
53+
default: getReactServerComponent,
54+
}));
55+
56+
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
57+
const wrapServerComponentRenderer = require('../src/wrapServerComponentRenderer/client.tsx').default;
58+
59+
return { wrapServerComponentRenderer, createRoot, hydrateRoot, render, unmount, getReactServerComponent };
60+
};
61+
62+
const cleanupWrappedRendererMocks = () => {
63+
document.body.innerHTML = '';
64+
};
65+
66+
describe('wrapServerComponentRenderer/client validation (issue #3647)', () => {
67+
const railsContext = { rscPayloadGenerationUrlPath: '/rsc_payload' };
68+
69+
beforeEach(() => {
70+
document.body.innerHTML = '';
71+
});
72+
73+
afterEach(() => {
74+
cleanupWrappedRendererMocks();
75+
});
76+
77+
it('rejects with an explicit message when domNodeId is missing', async () => {
78+
const { wrapServerComponentRenderer } = loadWrappedRendererWithMocks();
79+
const WrappedComponent = wrapServerComponentRenderer(() => null, 'MissingDomNodeIdComponent');
80+
81+
await expect(WrappedComponent({}, railsContext, undefined)).rejects.toThrow(
82+
"RSCClientRoot: No domNodeId provided for server component 'MissingDomNodeIdComponent'",
83+
);
84+
});
85+
86+
it('rejects with an explicit message when the target DOM node is missing', async () => {
87+
const { wrapServerComponentRenderer } = loadWrappedRendererWithMocks();
88+
const WrappedComponent = wrapServerComponentRenderer(() => null, 'MissingDomNodeComponent');
89+
90+
await expect(WrappedComponent({}, railsContext, 'missing-rsc-root')).rejects.toThrow(
91+
"RSCClientRoot: No DOM node found for id: missing-rsc-root (server component 'MissingDomNodeComponent')",
92+
);
93+
});
94+
95+
it('rejects with an explicit message when railsContext is missing', async () => {
96+
const { wrapServerComponentRenderer } = loadWrappedRendererWithMocks();
97+
const domNode = document.createElement('div');
98+
domNode.id = 'rsc-root-without-context';
99+
document.body.appendChild(domNode);
100+
const WrappedComponent = wrapServerComponentRenderer(() => null, 'MissingRailsContextComponent');
101+
102+
await expect(WrappedComponent({}, undefined, domNode.id)).rejects.toThrow(
103+
"RSCClientRoot: No railsContext provided for server component 'MissingRailsContextComponent'.",
104+
);
105+
});
106+
107+
it('rejects a render function that resolves to a renderer teardown result', async () => {
108+
const { wrapServerComponentRenderer } = loadWrappedRendererWithMocks();
109+
const renderFunction = (_props, _railsContext, _domNodeId) => ({ teardown: jest.fn() });
110+
const WrappedComponent = wrapServerComponentRenderer(renderFunction, 'TeardownResultComponent');
111+
112+
// No DOM node needed; the teardown-result guard throws before getElementById is called.
113+
await expect(WrappedComponent({}, railsContext, 'nonexistent-dom-id')).rejects.toThrow(
114+
"wrapServerComponentRenderer: render function for server component 'TeardownResultComponent' " +
115+
'returned a renderer teardown result; expected a React component.',
116+
);
117+
});
118+
});
119+
40120
describe('wrapServerComponentRenderer/client recoverable errors', () => {
41121
let originalReportErrorDescriptor;
42122

packages/react-on-rails/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"./ReactDOMServer": "./lib/ReactDOMServer.cjs",
5353
"./serverRenderReactComponent": "./lib/serverRenderReactComponent.js",
5454
"./@internal/sanitizeNonce": "./lib/sanitizeNonce.js",
55+
"./@internal/rendererTeardown": "./lib/rendererTeardown.js",
5556
"./@internal/base/client": "./lib/base/client.js",
5657
"./@internal/base/full": {
5758
"react-server": "./lib/base/full.rsc.js",

packages/react-on-rails/src/ClientRenderer.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type {
44
RailsContext,
55
RendererFunction,
66
RendererTeardown,
7-
RendererTeardownResult,
87
RenderReturnType,
98
} from './types/index.ts';
109
import ComponentRegistry from './ComponentRegistry.ts';
@@ -15,6 +14,7 @@ import { getRailsContext } from './context.ts';
1514
import { isServerRenderHash } from './isServerRenderResult.ts';
1615
import { onPageUnloaded } from './pageLifecycle.ts';
1716
import { supportsRootApi, unmountComponentAtNode } from './reactApis.cts';
17+
import { isRendererTeardownResult } from './rendererTeardown.ts';
1818

1919
const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
2020

@@ -41,14 +41,6 @@ function isThenable(value: unknown): value is PromiseLike<unknown> {
4141
);
4242
}
4343

44-
function isRendererTeardownResult(value: unknown): value is RendererTeardownResult {
45-
return (
46-
value != null &&
47-
typeof value === 'object' &&
48-
typeof (value as { teardown?: unknown }).teardown === 'function'
49-
);
50-
}
51-
5244
// Track all rendered roots for cleanup
5345
const renderedRoots = new Map<string, RenderedEntry>();
5446

packages/react-on-rails/src/createReactOutput.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
import { createElement, isValidElement, type ReactElement } from 'react';
2-
import type {
3-
CreateParams,
4-
ReactComponent,
5-
RenderFunction,
6-
CreateReactOutputResult,
7-
RendererTeardownResult,
8-
} from './types/index.ts';
2+
import type { CreateParams, ReactComponent, RenderFunction, CreateReactOutputResult } from './types/index.ts';
93
import { isServerRenderHash, isPromise } from './isServerRenderResult.ts';
4+
import { isRendererTeardownResult } from './rendererTeardown.ts';
105

116
const unsupportedManualRendererMessage = (name: string) =>
127
`ReactOnRails.render() does not support renderer functions ("${name}"). ` +
138
'Use normal React on Rails component rendering so renderer teardowns are captured on navigation.';
149

15-
function isRendererTeardownResult(value: unknown): value is RendererTeardownResult {
16-
return (
17-
value != null &&
18-
typeof value === 'object' &&
19-
typeof (value as { teardown?: unknown }).teardown === 'function'
20-
);
21-
}
22-
2310
function createReactElementFromRenderFunctionResult(
2411
renderFunctionResult: ReactComponent,
2512
name: string,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { RendererTeardownResult } from './types/index.ts';
2+
3+
// eslint-disable-next-line import/prefer-default-export -- single-export module; named export keeps the type guard's API tied to its predicate name.
4+
export function isRendererTeardownResult(value: unknown): value is RendererTeardownResult {
5+
return (
6+
value != null &&
7+
typeof value === 'object' &&
8+
typeof (value as { teardown?: unknown }).teardown === 'function'
9+
);
10+
}

0 commit comments

Comments
 (0)