Skip to content

Commit 573c89f

Browse files
justin808claude
andauthored
test(dummy): enable StrictMode in OSS and Pro dummies (#3206)
## Summary - Enables React StrictMode in the OSS `spec/dummy` startup paths by wrapping normal `ReactOnRails.register` registrations and direct manual renderer roots. - Covers both OSS dummy variants: the React 19 `client/app/` path and the React 16 `client/app-react16/` path. - Extends the same coverage to the Pro dummy with webpack aliases for the Pro package entrypoints, so async/generated registrations are patched before startup imports run. - Leaves 3-argument renderer functions in control of their own roots and wraps those manual roots explicitly. - Adds Jest coverage for the OSS StrictMode helper's wrapping, caching, and render-function skip behavior. Closes #1465 ## Test plan - [x] `pnpm --dir react_on_rails/spec/dummy run test:js` - [x] `pnpm --dir react_on_rails/spec/dummy run build:test` (passes with existing DefinePlugin warnings) - [x] `pnpm exec eslint --no-ignore react_on_rails_pro/spec/dummy/client/app/strictModeSupport.tsx react_on_rails_pro/spec/dummy/client/app/strictModeReactOnRailsPro.js react_on_rails_pro/spec/dummy/client/app/strictModeReactOnRailsProClient.js react_on_rails_pro/spec/dummy/config/webpack/alias.js react_on_rails_pro/spec/dummy/client/app/loadable/loadable-client.imports-loadable.jsx react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ManualRenderApp.jsx react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReduxApp.client.jsx react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/ReduxSharedStoreApp.client.jsx` - [x] `pnpm --dir react_on_rails_pro/spec/dummy run build:test` (passes with existing DefinePlugin warnings) - [x] pre-commit hook: trailing-newlines, Prettier, ESLint on Pro changed files - [x] pre-push hook: branch-lint, markdown-links <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enable React Strict Mode consistently for client render/hydration flows, registered components, and pro/auto-loaded entry points; add bundler/alias updates so client/server bundles resolve strict-mode-aware implementations. * **Tests** * Add comprehensive tests validating strict-mode wrapping behavior, wrapper caching, render-function exemptions, and opt-out handling. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk because changes are confined to `spec/dummy` apps and webpack configs used for dummy/test builds; main risk is unexpected StrictMode double-invocation effects causing flaky dummy tests or examples. > > **Overview** > **Enables React `StrictMode` across the OSS and Pro dummy apps** by adding shared helpers to wrap rendered element trees and to automatically wrap registered components. > > In OSS `spec/dummy`, startup entrypoints (including the React 16 variant) now wrap manual render roots and Redux provider trees via `wrapElementInStrictMode`, and `client-bundle.js` monkey-patches `ReactOnRails.register` to StrictMode-wrap non-render-function registrations. > > In Pro `spec/dummy`, a TypeScript `strictModeSupport` adds component/render-function wrapping (including async render results), webpack aliases redirect `react-on-rails-pro` imports to StrictMode-enabled shims for client/server builds (with RSC config explicitly removing that alias), and several startup apps wrap their roots explicitly; Jest coverage is added for the OSS helper. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 45da8de. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60b91e4 commit 573c89f

26 files changed

Lines changed: 729 additions & 24 deletions

react_on_rails/spec/dummy/client/app-react16/startup/ManualRenderApp.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
3+
// Intentional cross-tree import: the React 16 dummy entries reuse the StrictMode helper from the
4+
// React 19 `app/` tree. Keep the import path in sync if `app/strictModeSupport` is moved.
5+
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
36

47
export default (props, _railsContext, domNodeId) => {
5-
const reactElement = (
8+
const reactElement = wrapElementInStrictMode(
69
<div>
710
<h1 id="manual-render">Manual Render Example</h1>
811
<p>If you can see this, you can register renderer functions.</p>
9-
</div>
12+
</div>,
1013
);
1114

1215
const domNode = document.getElementById(domNodeId);

react_on_rails/spec/dummy/client/app-react16/startup/ReduxApp.client.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import reducers from '../../app/reducers/reducersIndex';
1212
import composeInitialState from '../../app/store/composeInitialState';
1313

1414
import HelloWorldContainer from '../../app/components/HelloWorldContainer';
15+
// Intentional cross-tree import: the React 16 dummy entries reuse the StrictMode helper from the
16+
// React 19 `app/` tree. Keep the import path in sync if `app/strictModeSupport` is moved.
17+
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
1518

1619
/*
1720
* Export a function that takes the props and returns a ReactComponent.
@@ -37,10 +40,10 @@ export default (props, railsContext, domNodeId) => {
3740
// Provider uses this.props.children, so we're not typical React syntax.
3841
// This allows redux to add additional props to the HelloWorldContainer.
3942
const renderApp = (Komponent) => {
40-
const element = (
43+
const element = wrapElementInStrictMode(
4144
<Provider store={store}>
4245
<Komponent />
43-
</Provider>
46+
</Provider>,
4447
);
4548

4649
render(element, document.getElementById(domNodeId));

react_on_rails/spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import ReactOnRails from 'react-on-rails/client';
77
import ReactDOM from 'react-dom';
88

99
import HelloWorldContainer from '../../app/components/HelloWorldContainer';
10+
// Intentional cross-tree import: the React 16 dummy entries reuse the StrictMode helper from the
11+
// React 19 `app/` tree. Keep the import path in sync if `app/strictModeSupport` is moved.
12+
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
1013

1114
/*
1215
* Export a function that returns a ReactComponent, depending on a store named SharedReduxStore.
@@ -27,10 +30,10 @@ export default (props, _railsContext, domNodeId) => {
2730
// Provider uses this.props.children, so we're not typical React syntax.
2831
// This allows redux to add additional props to the HelloWorldContainer.
2932
const renderApp = (Component) => {
30-
const element = (
33+
const element = wrapElementInStrictMode(
3134
<Provider store={store}>
3235
<Component />
33-
</Provider>
36+
</Provider>,
3437
);
3538
render(element, document.getElementById(domNodeId));
3639
};

react_on_rails/spec/dummy/client/app/packs/client-bundle.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ import ReactOnRails from 'react-on-rails/client';
99
import HelloTurboStream from '../startup/HelloTurboStream';
1010
import ManualRenderComponent from '../startup/ManualRenderComponent';
1111
import SharedReduxStore from '../stores/SharedReduxStore';
12+
import { wrapRegisteredComponentsWithStrictMode } from '../strictModeSupport';
13+
14+
const STRICT_MODE_PATCHED = '__reactOnRailsDummyStrictModePatched';
15+
16+
// Scope: this patch only affects `ReactOnRails.register` calls that share this bundle's
17+
// `react-on-rails/client` module instance. Coverage today:
18+
// - This pack and its imports (HelloTurboStream, ManualRenderComponent, SharedReduxStore).
19+
// - The auto-generated `packs/generated/*.js` entries — they share this `ReactOnRails` instance
20+
// because shakapacker's webpack build produces one runtime per compilation, and they run
21+
// after `client-bundle.js` on every page that loads them.
22+
// - Inline ERB views that call `ReactOnRails.register` after this pack has run.
23+
// - Manual-render trees in `startup/` and `app-react16/startup/` already wrap with
24+
// `wrapElementInStrictMode` directly (they don't need this `register` patch).
25+
//
26+
// What's NOT covered: any future pack that imports `react-on-rails/client` from a separate
27+
// webpack compilation (e.g., a standalone bundle config) would get its own unpatched module
28+
// instance. When adding a pack like that, either fold it into this compilation or duplicate
29+
// this `STRICT_MODE_PATCHED` block at the top of the new pack before any `register` calls.
30+
if (!ReactOnRails[STRICT_MODE_PATCHED]) {
31+
const originalRegister = ReactOnRails.register.bind(ReactOnRails);
32+
33+
ReactOnRails.register = (components) =>
34+
originalRegister(wrapRegisteredComponentsWithStrictMode(components));
35+
Object.defineProperty(ReactOnRails, STRICT_MODE_PATCHED, { value: true });
36+
}
1237

1338
ReactOnRails.setOptions({
1439
traceTurbolinks: true,

react_on_rails/spec/dummy/client/app/startup/ManualRenderApp.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React from 'react';
22
import ReactDOMClient from 'react-dom/client';
3+
import { wrapElementInStrictMode } from '../strictModeSupport';
34

45
export default (props, _railsContext, domNodeId) => {
5-
const reactElement = (
6+
const reactElement = wrapElementInStrictMode(
67
<div>
78
<h1 id="manual-render">Manual Render Example</h1>
89
<p>If you can see this, you can register renderer functions.</p>
9-
</div>
10+
</div>,
1011
);
1112

1213
const domNode = document.getElementById(domNodeId);

react_on_rails/spec/dummy/client/app/startup/ReduxApp.client.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import reducers from '../reducers/reducersIndex';
1212
import composeInitialState from '../store/composeInitialState';
1313

1414
import HelloWorldContainer from '../components/HelloWorldContainer';
15+
import { wrapElementInStrictMode } from '../strictModeSupport';
1516

1617
/*
1718
* Export a function that takes the props and returns a ReactComponent.
@@ -42,10 +43,10 @@ export default (props, railsContext, domNodeId) => {
4243
// Provider uses this.props.children, so we're not typical React syntax.
4344
// This allows redux to add additional props to the HelloWorldContainer.
4445
const renderApp = (Komponent) => {
45-
const element = (
46+
const element = wrapElementInStrictMode(
4647
<Provider store={store}>
4748
<Komponent />
48-
</Provider>
49+
</Provider>,
4950
);
5051

5152
render(document.getElementById(domNodeId), element);

react_on_rails/spec/dummy/client/app/startup/ReduxSharedStoreApp.client.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ReactOnRails from 'react-on-rails/client';
77
import ReactDOMClient from 'react-dom/client';
88

99
import HelloWorldContainer from '../components/HelloWorldContainer';
10+
import { wrapElementInStrictMode } from '../strictModeSupport';
1011

1112
/*
1213
* Export a function that returns a ReactComponent, depending on a store named SharedReduxStore.
@@ -32,10 +33,10 @@ export default (props, _railsContext, domNodeId) => {
3233
// Provider uses this.props.children, so we're not typical React syntax.
3334
// This allows redux to add additional props to the HelloWorldContainer.
3435
const renderApp = (Component) => {
35-
const element = (
36+
const element = wrapElementInStrictMode(
3637
<Provider store={store}>
3738
<Component />
38-
</Provider>
39+
</Provider>,
3940
);
4041
render(document.getElementById(domNodeId), element);
4142
};
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import React from 'react';
2+
3+
const REACT_OBJECT_COMPONENT_TYPES = new Set([
4+
Symbol.for('react.forward_ref'),
5+
Symbol.for('react.lazy'),
6+
Symbol.for('react.memo'),
7+
]);
8+
9+
const wrappedFunctionComponents = new WeakMap();
10+
const wrappedRenderFunctions = new WeakMap();
11+
// Object-typed React components (memo, forwardRef, lazy) are GC-safe in a WeakMap.
12+
const wrappedObjectComponents = new WeakMap();
13+
14+
const isPromiseLike = (value) =>
15+
typeof value === 'object' && value !== null && typeof value.then === 'function';
16+
17+
const isFunctionWithMetadata = (component) => typeof component === 'function';
18+
19+
const isObjectComponent = (component) =>
20+
typeof component === 'object' &&
21+
component !== null &&
22+
REACT_OBJECT_COMPONENT_TYPES.has(component.$$typeof ?? 0);
23+
24+
const isReactComponent = (component) => isFunctionWithMetadata(component) || isObjectComponent(component);
25+
26+
// Mirrors the public react-on-rails/isRenderFunction convention while extending it with an
27+
// explicit `renderFunction = false` opt-out (the public helper only checks for truthiness; this
28+
// dummy honors `false` so legacy 2-arg `(props, context)` components can be wrapped via the
29+
// component path rather than the render-function path). Set `fn.renderFunction = true` to flag a
30+
// 1-arg render function explicitly. Class components are detected via the prototype check.
31+
const isRenderFunction = (component) => {
32+
if (typeof component !== 'function') {
33+
return false;
34+
}
35+
36+
if (component.prototype?.isReactComponent) {
37+
return false;
38+
}
39+
40+
if (typeof component.renderFunction === 'boolean') {
41+
return component.renderFunction;
42+
}
43+
44+
return component.length >= 2;
45+
};
46+
47+
// A 3-arg renderer controls its own root, so direct renderer files wrap their root elements explicitly.
48+
const isRendererFunction = (component) => isRenderFunction(component) && component.length === 3;
49+
50+
const createStrictModeWrapper = (Component) => {
51+
function StrictModeWrapper(props) {
52+
return <React.StrictMode>{React.createElement(Component, props)}</React.StrictMode>;
53+
}
54+
55+
StrictModeWrapper.displayName = `StrictMode(${Component.displayName || Component.name || 'AnonymousComponent'})`;
56+
57+
return StrictModeWrapper;
58+
};
59+
60+
const wrapComponentInStrictMode = (component) => {
61+
if (typeof component === 'function') {
62+
const cachedComponent = wrappedFunctionComponents.get(component);
63+
if (cachedComponent) {
64+
return cachedComponent;
65+
}
66+
67+
const wrappedComponent = createStrictModeWrapper(component);
68+
wrappedFunctionComponents.set(component, wrappedComponent);
69+
return wrappedComponent;
70+
}
71+
72+
const cachedComponent = wrappedObjectComponents.get(component);
73+
if (cachedComponent) {
74+
return cachedComponent;
75+
}
76+
77+
const wrappedComponent = createStrictModeWrapper(component);
78+
wrappedObjectComponents.set(component, wrappedComponent);
79+
return wrappedComponent;
80+
};
81+
82+
export const wrapElementInStrictMode = (reactElement) => <React.StrictMode>{reactElement}</React.StrictMode>;
83+
84+
const wrapRenderFunctionResult = (result) => {
85+
if (isPromiseLike(result)) {
86+
return result.then(wrapRenderFunctionResult);
87+
}
88+
89+
if (React.isValidElement(result)) {
90+
return wrapElementInStrictMode(result);
91+
}
92+
93+
if (isReactComponent(result)) {
94+
return wrapComponentInStrictMode(result);
95+
}
96+
97+
return result;
98+
};
99+
100+
// The wrapped function below has `length === 2`, so `isRenderFunction` would re-classify it as a
101+
// render function if it ever flowed back through `wrapRegisteredComponentsWithStrictMode`. The
102+
// `STRICT_MODE_PATCHED` guard on the patched `register` call (in `client-bundle.js`) keeps that
103+
// re-entry path unreachable. Note: when called directly (outside the registry path), the wrapper's
104+
// hardcoded `length === 2` does not reflect the original function's arity.
105+
const wrapRenderFunctionInStrictMode = (renderFunction) => {
106+
const cachedRenderFunction = wrappedRenderFunctions.get(renderFunction);
107+
if (cachedRenderFunction) {
108+
return cachedRenderFunction;
109+
}
110+
111+
const wrappedRenderFunction = function StrictModeRenderFunction(props, railsContext) {
112+
return wrapRenderFunctionResult(renderFunction(props, railsContext));
113+
};
114+
wrappedRenderFunction.displayName = `StrictMode(${
115+
renderFunction.displayName || renderFunction.name || 'AnonymousRenderFunction'
116+
})`;
117+
118+
if (renderFunction.renderFunction) {
119+
wrappedRenderFunction.renderFunction = true;
120+
}
121+
122+
wrappedRenderFunctions.set(renderFunction, wrappedRenderFunction);
123+
return wrappedRenderFunction;
124+
};
125+
126+
export const wrapRegisteredComponentsWithStrictMode = (components) =>
127+
Object.fromEntries(
128+
Object.entries(components).map(([name, component]) => {
129+
if (isRendererFunction(component)) {
130+
return [name, component];
131+
}
132+
133+
if (isRenderFunction(component)) {
134+
return [name, wrapRenderFunctionInStrictMode(component)];
135+
}
136+
137+
if (!isReactComponent(component)) {
138+
return [name, component];
139+
}
140+
141+
return [name, wrapComponentInStrictMode(component)];
142+
}),
143+
);

0 commit comments

Comments
 (0)