Skip to content

Commit b4b229f

Browse files
justin808claude
andauthored
fix(node-renderer): expose performance in VM context when supportModules (#3158)
## Summary - Adds `performance` to the default globals injected into the node renderer's VM context when `supportModules: true`. - React 19's development build of `React.lazy` calls `performance.now()` inside `lazyInitializer`, which threw `ReferenceError: performance is not defined` without this — making `React.lazy` unusable in dev unless users manually passed `performance` via `additionalContext`. - Updates the `Config` JSDoc, the `js-configuration.md` docs, and the `CHANGELOG`, and adds a regression test. Fixes #3154. ## Notes The reporter also raised a broader question about the recommended pattern for `React.lazy` with streaming SSR and `LimitChunkCountPlugin({ maxChunks: 1 })` (intermittent Suspense resolution causing hydration mismatches / React error #419). That's a separate, larger discussion and is not addressed here — this PR only fixes the concrete `performance` VM-context bug the reporter identified and worked around. ## Test plan - [x] `pnpm --filter react-on-rails-pro-node-renderer exec jest vm.test` — all 27 tests pass, including the new assertions that `performance` and `performance.now` are defined in the VM when `supportModules` is enabled. - [x] `pnpm exec prettier --check` on the modified files — passes. - [x] `pnpm -w run eslint` on the modified source files — passes. Generated with Claude Code (https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: adds one additional global (`performance`) to the VM context behind the existing `supportModules` flag, plus doc/test updates; main behavioral change is potential nondeterministic SSR output if app code embeds `performance.now()` values. > > **Overview** > Fixes React 19 dev SSR crashes by adding the host `performance` object to the node renderer VM globals when `supportModules: true`, alongside existing injected Node globals. > > Updates docs and JSDoc to reflect the new default global set and to clarify that `stubTimers` does not stub `performance` (with guidance for overriding via `additionalContext`), and extends VM tests to assert `performance`/`performance.now` presence when enabled (and absence when disabled). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 54b4aaf. 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** * Restored React 19 dev compatibility by ensuring the renderer VM includes the performance global when module support is enabled, preventing ReferenceError in lazy-loaded code. * **Documentation** * Clarified that performance is included in the VM globals when module support is enabled and noted behavior relative to timer stubbing and SSR determinism. * **Tests** * Updated tests to assert performance and performance.now availability in the VM sandbox. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ced93f commit b4b229f

File tree

6 files changed

+33
-14
lines changed

6 files changed

+33
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
3030

3131
#### Fixed
3232

33+
- **[Pro]** **Node renderer now exposes `performance` when `supportModules: true`**: React 19's development build of `React.lazy` calls `performance.now()`, which previously threw `ReferenceError: performance is not defined` inside the node renderer's VM context unless users manually added `performance` via `additionalContext`. `performance` is now included in the default globals alongside `Buffer`, `process`, etc. Fixes [Issue 3154](https://github.com/shakacode/react_on_rails/issues/3154). [PR 3158](https://github.com/shakacode/react_on_rails/pull/3158) by [justin808](https://github.com/justin808).
3334
- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
3435
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
3536
- **Doctor no longer fails custom projects for a missing generated `bin/dev`**: `react_on_rails:doctor` now downgrades a missing official React on Rails `bin/dev` launcher from an error to a warning and adds explicit guidance when a custom `./dev` script is detected, so custom projects can pass diagnostics when their development setup is intentional. Fixes [Issue 3103](https://github.com/shakacode/react_on_rails/issues/3103). [PR 3117](https://github.com/shakacode/react_on_rails/pull/3117) by [justin808](https://github.com/justin808).

docs/oss/building-features/node-renderer/js-configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ available default ENV values if you wire them into your own launch script.
3434
1. **gracefulWorkerRestartTimeout**: (default: `env.GRACEFUL_WORKER_RESTART_TIMEOUT`) - Time in seconds that the master waits for a worker to gracefully restart (after serving all active requests) before killing it. Use this when you want to avoid situations where a worker gets stuck in an infinite loop and never restarts. This config is only usable if worker restart is enabled. The timeout starts when the worker should restart; if it elapses without a restart, the worker is killed.
3535
1. **maxDebugSnippetLength** (default: 1000) - If the rendering request is longer than this, it will be truncated in exception and logging messages.
3636
1. **supportModules** - (default: `env.RENDERER_SUPPORT_MODULES || null`) - If set to true, `supportModules` enables the server-bundle code to call a default set of NodeJS global objects and functions that get added to the VM context:
37-
`{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }`.
37+
`{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, performance, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }`.
38+
`performance` is included so React 19's development build can call `performance.now()` from `React.lazy` without throwing `ReferenceError: performance is not defined`.
3839
This option is required to equal `true` if you want to use loadable components.
3940
Setting this value to false causes the NodeRenderer to behave like ExecJS.
4041
See also `stubTimers`.
@@ -45,6 +46,7 @@ available default ENV values if you wire them into your own launch script.
4546
This is useful when using dependencies like [react-virtuoso](https://github.com/petyosi/react-virtuoso) that use these functions during hydration.
4647
In RORP, hydration typically is synchronous and single-task (unless you use streaming) and thus callbacks passed to task-scheduling functions should never run during server-side rendering.
4748
Because these functions are valid client-side, they are ignored on server-side rendering without errors or warnings.
49+
Note that `performance` (exposed when `supportModules: true`) is the host's real `performance` object and is **not** stubbed by `stubTimers`; if rendered output embeds `performance.now()` values (e.g., dev-only timing annotations) they will vary between renders. Override via `additionalContext` (e.g., `{ performance: { now: () => 0 } }`) if strict SSR determinism is required.
4850
See also `supportModules`.
4951

5052
Deprecated options:

docs/oss/migrating/rsc-troubleshooting.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,7 @@ for the current recommendation. See also
818818
// renderer/node-renderer.js (or wherever you configure the renderer)
819819
module.exports = {
820820
supportModules: true, // Injects: Buffer, TextDecoder, TextEncoder,
821-
// URLSearchParams, ReadableStream, process,
821+
// URLSearchParams, ReadableStream, process, performance,
822822
// setTimeout, setInterval, setImmediate,
823823
// clearTimeout, clearInterval, clearImmediate,
824824
// queueMicrotask
@@ -831,13 +831,16 @@ Or set the environment variable:
831831
RENDERER_SUPPORT_MODULES=true
832832
```
833833

834-
For globals not covered by `supportModules` (e.g., `performance`), use `additionalContext`:
834+
For globals not covered by `supportModules`, use `additionalContext`:
835835

836836
```js
837837
module.exports = {
838838
supportModules: true,
839839
additionalContext: {
840-
performance: require('perf_hooks').performance,
840+
// Add any globals that aren't in the default supportModules set.
841+
// Example: override `performance` with a deterministic stub if rendered
842+
// output embeds timing values and you need byte-stable SSR.
843+
performance: { now: () => 0 },
841844
},
842845
};
843846
```

packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface Config {
4444
bundlePath?: string;
4545
// If set to true, `supportModules` enables the server-bundle code to call a default set of NodeJS
4646
// global objects and functions that get added to the VM context:
47-
// `{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }`.
47+
// `{ Buffer, TextDecoder, TextEncoder, URLSearchParams, ReadableStream, process, performance, setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate, queueMicrotask }`.
4848
// This option is required to equal `true` if you want to use loadable components.
4949
// Setting this value to false causes the NodeRenderer to behave like ExecJS.
5050
supportModules: boolean;

packages/react-on-rails-pro-node-renderer/src/worker/vm.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,15 +215,17 @@ export async function buildVM(filePath: string) {
215215

216216
if (supportModules) {
217217
// IMPORTANT: When adding anything to this object, update:
218-
// 1. docs/node-renderer/js-configuration.md
219-
// 2. packages/node-renderer/src/shared/configBuilder.ts
218+
// 1. docs/oss/building-features/node-renderer/js-configuration.md
219+
// 2. packages/react-on-rails-pro-node-renderer/src/shared/configBuilder.ts (JSDoc on `supportModules`)
220+
// 3. docs/oss/migrating/rsc-troubleshooting.md ("Node Renderer VM Context -- Missing Globals")
220221
extendContext(contextObject, {
221222
Buffer,
222223
TextDecoder,
223224
TextEncoder,
224225
URLSearchParams,
225226
ReadableStream,
226227
process,
228+
performance,
227229
setTimeout,
228230
setInterval,
229231
setImmediate,

packages/react-on-rails-pro-node-renderer/tests/vm.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('buildVM and runInVM', () => {
2626
await resetForTest(testName);
2727
});
2828

29-
describe('Buffer and process in context', () => {
29+
describe('default VM globals (Buffer, process, performance)', () => {
3030
test('not available if supportModules disabled', async () => {
3131
const config = getConfig();
3232
config.supportModules = false;
@@ -35,10 +35,13 @@ describe('buildVM and runInVM', () => {
3535
await buildVM(uploadedBundlePathForTest());
3636

3737
let result = await runInVM('typeof Buffer === "undefined"', uploadedBundlePathForTest());
38-
expect(result).toBeTruthy();
38+
expect(result).toBe('true');
3939

4040
result = await runInVM('typeof process === "undefined"', uploadedBundlePathForTest());
41-
expect(result).toBeTruthy();
41+
expect(result).toBe('true');
42+
43+
result = await runInVM('typeof performance === "undefined"', uploadedBundlePathForTest());
44+
expect(result).toBe('true');
4245
});
4346

4447
test('available if supportModules enabled', async () => {
@@ -49,10 +52,18 @@ describe('buildVM and runInVM', () => {
4952
await buildVM(uploadedBundlePathForTest());
5053

5154
let result = await runInVM('typeof Buffer !== "undefined"', uploadedBundlePathForTest());
52-
expect(result).toBeTruthy();
55+
expect(result).toBe('true');
5356

5457
result = await runInVM('typeof process !== "undefined"', uploadedBundlePathForTest());
55-
expect(result).toBeTruthy();
58+
expect(result).toBe('true');
59+
60+
// React 19's development build of `React.lazy` calls `performance.now()`,
61+
// so `performance` must be available when `supportModules` is enabled.
62+
result = await runInVM('typeof performance !== "undefined"', uploadedBundlePathForTest());
63+
expect(result).toBe('true');
64+
65+
result = await runInVM('typeof performance.now === "function"', uploadedBundlePathForTest());
66+
expect(result).toBe('true');
5667
});
5768
});
5869

@@ -62,7 +73,7 @@ describe('buildVM and runInVM', () => {
6273
await buildVM(uploadedBundlePathForTest());
6374

6475
const result = await runInVM('typeof testString === "undefined"', uploadedBundlePathForTest());
65-
expect(result).toBeTruthy();
76+
expect(result).toBe('true');
6677
});
6778

6879
test('available if additionalContext set', async () => {
@@ -73,7 +84,7 @@ describe('buildVM and runInVM', () => {
7384
await buildVM(uploadedBundlePathForTest());
7485

7586
const result = await runInVM('typeof testString !== "undefined"', uploadedBundlePathForTest());
76-
expect(result).toBeTruthy();
87+
expect(result).toBe('true');
7788
});
7889
});
7990

0 commit comments

Comments
 (0)