Skip to content

Commit 3dd51c9

Browse files
justin808claude
andcommitted
test(dummy): address StrictMode review feedback
Picks up the optional-tier review feedback on PR #3206: - RSC bundle now also drops the `react-on-rails-pro/client$` alias so the client-only StrictMode shim cannot resolve inside the React server bundle. - `serverWebpackConfig` writes its alias override via a defensive spread rather than mutating `resolve.alias` directly. - OSS `strictModeSupport` splits the non-function cache into a WeakMap for object components (memo/forwardRef/lazy) and a Map for string keys, so registered objects are no longer pinned for the lifetime of the module. - OSS `isRenderFunction` documents the `length >= 2` heuristic and how to opt out of it. - `client-bundle.js` documents the bundle-scope limitation of the register patch. - Adds a Jest case that pins down 2-arg functional component handling. - React 16 dummy startup files document the intentional cross-tree import of `app/strictModeSupport`. - Pro `wrapRenderFunctionInStrictMode` documents the `length === 2` invariant and why STRICT_MODE_PATCHED keeps it safe. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 45da8de commit 3dd51c9

9 files changed

Lines changed: 65 additions & 12 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
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.
35
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
46

57
export default (props, _railsContext, domNodeId) => {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ 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.
1517
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
1618

1719
/*

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ 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.
1012
import { wrapElementInStrictMode } from '../../app/strictModeSupport';
1113

1214
/*

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import { wrapRegisteredComponentsWithStrictMode } from '../strictModeSupport';
1313

1414
const STRICT_MODE_PATCHED = '__reactOnRailsDummyStrictModePatched';
1515

16+
// Scope: this patch only affects `ReactOnRails.register` calls that share this bundle's module
17+
// instance (this pack and inline ERB views that run after it). Separate entry-point packs that
18+
// import `react-on-rails/client` independently get their own unpatched module and would skip
19+
// StrictMode wrapping.
1620
if (!ReactOnRails[STRICT_MODE_PATCHED]) {
1721
const originalRegister = ReactOnRails.register.bind(ReactOnRails);
1822

19-
// Covers this bundle and inline ERB register calls, which run after the bundle has loaded.
2023
ReactOnRails.register = (components) =>
2124
originalRegister(wrapRegisteredComponentsWithStrictMode(components));
2225
Object.defineProperty(ReactOnRails, STRICT_MODE_PATCHED, { value: true });

react_on_rails/spec/dummy/client/app/strictModeSupport.jsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import React from 'react';
22

33
const wrappedFunctionComponents = new WeakMap();
4-
const wrappedOtherComponents = new Map(); // Map, not WeakMap: string component names are valid keys.
4+
// Object-typed React components (memo, forwardRef, lazy) are GC-safe in a WeakMap.
5+
const wrappedObjectComponents = new WeakMap();
6+
// Strings are primitives and cannot key a WeakMap, so registered string component names live here.
7+
const wrappedStringComponents = new Map();
58

69
// Mirrors the public react-on-rails/isRenderFunction convention for this dummy-only wrapper.
10+
// Note: the `length >= 2` heuristic intentionally classifies any 2-arg function as a render
11+
// function. Legacy 2-arg components that use the (props, context) signature must opt back in by
12+
// setting `fn.renderFunction = false` (or, for class components, are detected via the prototype
13+
// check). Set `fn.renderFunction = true` to flag a 1-arg render function explicitly.
714
const isRenderFunction = (component) => {
815
if (typeof component !== 'function') {
916
return false;
@@ -46,13 +53,24 @@ const wrapComponentInStrictMode = (component) => {
4653
return wrappedComponent;
4754
}
4855

49-
const cachedComponent = wrappedOtherComponents.get(component);
56+
if (typeof component === 'string') {
57+
const cachedComponent = wrappedStringComponents.get(component);
58+
if (cachedComponent) {
59+
return cachedComponent;
60+
}
61+
62+
const wrappedComponent = createStrictModeWrapper(component);
63+
wrappedStringComponents.set(component, wrappedComponent);
64+
return wrappedComponent;
65+
}
66+
67+
const cachedComponent = wrappedObjectComponents.get(component);
5068
if (cachedComponent) {
5169
return cachedComponent;
5270
}
5371

5472
const wrappedComponent = createStrictModeWrapper(component);
55-
wrappedOtherComponents.set(component, wrappedComponent);
73+
wrappedObjectComponents.set(component, wrappedComponent);
5674
return wrappedComponent;
5775
};
5876

react_on_rails/spec/dummy/tests/strict-mode-support.test.jsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ describe('strictModeSupport', () => {
6464
expect(wrappedComponents.rendererFunction).toBe(rendererFunction);
6565
});
6666

67+
it('treats a 2-arg functional component as a render function (arity heuristic)', () => {
68+
// A 2-arg component looks like a render function to isRenderFunction; this test pins down
69+
// the heuristic so a future change has to update the test alongside the behavior.
70+
const TwoArgComponent = ({ greeting }, _legacyContext) => <div>{greeting}</div>;
71+
TwoArgComponent.propTypes = {
72+
greeting: PropTypes.string.isRequired,
73+
};
74+
75+
const wrappedComponents = wrapRegisteredComponentsWithStrictMode({ TwoArgComponent });
76+
77+
expect(wrappedComponents.TwoArgComponent).toBe(TwoArgComponent);
78+
});
79+
6780
it('wraps manual render trees in StrictMode', () => {
6881
const innerElement = <div>hello</div>;
6982
const wrappedElement = wrapElementInStrictMode(innerElement);

react_on_rails_pro/spec/dummy/client/app/strictModeSupport.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ const wrapRenderFunctionResult = (result: unknown): unknown => {
145145
return result;
146146
};
147147

148+
// The wrapped function below has `length === 2`, so `isRenderFunction` would re-classify it as a
149+
// render function if it ever flowed back through `wrapRegisteredComponentsWithStrictMode`. The
150+
// STRICT_MODE_PATCHED guard on `enableStrictModeForReactOnRails` ensures the patched `register`
151+
// runs only once per singleton, which keeps that re-entry path unreachable.
148152
const wrapRenderFunctionInStrictMode = (renderFunction: RenderFunction): RenderFunction => {
149153
const cachedRenderFunction = wrappedRenderFunctions.get(renderFunction);
150154
if (cachedRenderFunction) {

react_on_rails_pro/spec/dummy/config/webpack/rscWebpackConfig.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ const configureRsc = () => {
3636
const rootNodeModules = resolve(__dirname, '..', '..', '..', '..', '..', 'node_modules');
3737
const rscAliases = { ...(rscConfig.resolve?.alias || {}) };
3838
delete rscAliases['react-on-rails-pro$'];
39+
// Drop the client-only StrictMode shim so RSC imports of `react-on-rails-pro/client` don't pull
40+
// in a browser entry point inside the React server bundle.
41+
delete rscAliases['react-on-rails-pro/client$'];
3942
rscConfig.resolve = {
4043
...rscConfig.resolve,
4144
conditionNames: ['react-server', '...'],

react_on_rails_pro/spec/dummy/config/webpack/serverWebpackConfig.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@ const configureServer = (rscBundle = false) => {
2525
// entry value will result in changing the client config!
2626
// Using webpack-merge into an empty object avoids this issue.
2727
const serverWebpackConfig = commonWebpackConfig();
28-
serverWebpackConfig.resolve.alias['react-on-rails-pro$'] = path.resolve(
29-
__dirname,
30-
'..',
31-
'..',
32-
'client',
33-
'app',
34-
'strictModeReactOnRailsProNode.js',
35-
);
28+
serverWebpackConfig.resolve = {
29+
...serverWebpackConfig.resolve,
30+
alias: {
31+
...(serverWebpackConfig.resolve?.alias || {}),
32+
'react-on-rails-pro$': path.resolve(
33+
__dirname,
34+
'..',
35+
'..',
36+
'client',
37+
'app',
38+
'strictModeReactOnRailsProNode.js',
39+
),
40+
},
41+
};
3642

3743
// We just want the single server bundle entry
3844
const serverEntry = {

0 commit comments

Comments
 (0)