Skip to content

Commit 17874e2

Browse files
feat: React 19.2 support (#52)
1 parent c9ed346 commit 17874e2

8 files changed

Lines changed: 177 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Starting with `1.x`, `test-renderer` tracks preferred React 19 compatibility lin
4848
| `1.1.x` | `19.1` | `~0.32.0` | Owner Stack support, CSS-selector-safe `useId()` format |
4949
| `1.2.x` | `19.2` | `~0.33.0` | `<Activity />`, `useEffectEvent` |
5050

51-
These examples are illustrative, not exhaustive. The `1.0.x` and `1.1.x` lines are current, and `1.2.x` is the next planned React 19 line. New React-minor-specific support lands on the matching preferred React / `react-reconciler` line for each `1.x` release, even though the package publishes a broad React 19 peer range.
51+
These examples are illustrative, not exhaustive. The `1.0.x`, `1.1.x`, and `1.2.x` lines are current compatibility lines. New React-minor-specific support lands on the matching preferred React / `react-reconciler` line for each `1.x` release, even though the package publishes a broad React 19 peer range.
5252

5353
## Test Output Tree
5454

bun.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/versioning.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,15 @@ When multiple `1.x` compatibility lines are live, direct `test-renderer` consume
138138

139139
This keeps `npm` install flows predictable for users who consume `test-renderer` directly.
140140

141-
During the rollout to later React 19 lines, `latest` may temporarily lag the highest planned compatibility line. Dist tags become more useful once there is more than one active `1.x` line.
141+
During rollouts to later React 19 lines, `latest` may temporarily lag the highest published compatibility line. Dist tags become more useful once there is more than one active `1.x` line.
142142

143143
## Summary
144144

145145
The recommended `test-renderer` 1.x scheme is:
146146

147147
- `1.0.x` for React `19.0`
148148
- `1.1.x` for React `19.1`
149-
- `1.2.x` for React `19.2` when that line is published
149+
- `1.2.x` for React `19.2`
150150

151151
With this scheme:
152152

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@
5151
"release": "release-it"
5252
},
5353
"dependencies": {
54-
"@types/react-reconciler": "~0.32.0",
55-
"react-reconciler": "~0.32.0"
54+
"@types/react-reconciler": "~0.33.0",
55+
"react-reconciler": "~0.33.0"
5656
},
5757
"devDependencies": {
5858
"@eslint/js": "^10.0.1",

src/__tests__/activity.test.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, expect } from "@jest/globals";
2+
import { Activity, useState } from "react";
3+
4+
import type { Props } from "../reconciler";
5+
import { createRoot } from "../renderer";
6+
import { testGateReact19_2 } from "../test-utils/react-version";
7+
import { renderWithAct } from "../test-utils/render";
8+
9+
beforeEach(() => {
10+
global.IS_REACT_ACT_ENVIRONMENT = true;
11+
});
12+
13+
const transformHiddenInstanceProps = ({ props }: { props: Props }) => ({
14+
...props,
15+
"data-is-hidden": true,
16+
});
17+
18+
testGateReact19_2("Activity hides content while preserving child state", async () => {
19+
let nextStateId = 0;
20+
21+
function CounterPane() {
22+
const [stateId] = useState(() => ++nextStateId);
23+
return <div>Pane state: {stateId}</div>;
24+
}
25+
26+
function App({ mode }: { mode: "hidden" | "visible" }) {
27+
return (
28+
<Activity mode={mode}>
29+
<CounterPane />
30+
</Activity>
31+
);
32+
}
33+
34+
const renderer = createRoot();
35+
36+
await renderWithAct(renderer, <App mode="visible" />);
37+
expect(renderer.container).toMatchInlineSnapshot(`
38+
<>
39+
<div>
40+
Pane state:
41+
1
42+
</div>
43+
</>
44+
`);
45+
46+
await renderWithAct(renderer, <App mode="hidden" />);
47+
expect(renderer.container).toMatchInlineSnapshot(`< />`);
48+
49+
await renderWithAct(renderer, <App mode="visible" />);
50+
expect(renderer.container).toMatchInlineSnapshot(`
51+
<>
52+
<div>
53+
Pane state:
54+
1
55+
</div>
56+
</>
57+
`);
58+
});
59+
60+
testGateReact19_2(
61+
"Activity keeps hidden instances in output with transformHiddenInstanceProps",
62+
async () => {
63+
function Pane() {
64+
return <div>Content</div>;
65+
}
66+
67+
function App({ mode }: { mode: "hidden" | "visible" }) {
68+
return (
69+
<Activity mode={mode}>
70+
<Pane />
71+
</Activity>
72+
);
73+
}
74+
75+
const renderer = createRoot({ transformHiddenInstanceProps });
76+
77+
await renderWithAct(renderer, <App mode="visible" />);
78+
expect(renderer.container).toMatchInlineSnapshot(`
79+
<>
80+
<div>
81+
Content
82+
</div>
83+
</>
84+
`);
85+
86+
await renderWithAct(renderer, <App mode="hidden" />);
87+
expect(renderer.container).toMatchInlineSnapshot(`
88+
<>
89+
<div
90+
data-is-hidden={true}
91+
>
92+
Content
93+
</div>
94+
</>
95+
`);
96+
97+
await renderWithAct(renderer, <App mode="visible" />);
98+
expect(renderer.container).toMatchInlineSnapshot(`
99+
<>
100+
<div>
101+
Content
102+
</div>
103+
</>
104+
`);
105+
},
106+
);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { beforeEach, expect } from "@jest/globals";
2+
import { useEffect, useEffectEvent } from "react";
3+
4+
import { createRoot } from "../renderer";
5+
import { testGateReact19_2 } from "../test-utils/react-version";
6+
import { renderWithAct } from "../test-utils/render";
7+
8+
beforeEach(() => {
9+
global.IS_REACT_ACT_ENVIRONMENT = true;
10+
});
11+
12+
testGateReact19_2(
13+
"useEffectEvent reads the latest value when an effect is triggered by another dependency",
14+
async () => {
15+
const observedValues: string[] = [];
16+
17+
function Subscriber({ trigger, value }: { trigger: number; value: string }) {
18+
const onEffectEvent = useEffectEvent(() => {
19+
observedValues.push(value);
20+
});
21+
22+
useEffect(() => {
23+
onEffectEvent();
24+
}, [trigger]);
25+
26+
return <div>{value}</div>;
27+
}
28+
29+
const renderer = createRoot();
30+
31+
await renderWithAct(renderer, <Subscriber trigger={0} value="first" />);
32+
expect(observedValues).toEqual(["first"]);
33+
34+
await renderWithAct(renderer, <Subscriber trigger={0} value="second" />);
35+
expect(observedValues).toEqual(["first"]);
36+
37+
await renderWithAct(renderer, <Subscriber trigger={1} value="second" />);
38+
expect(observedValues).toEqual(["first", "second"]);
39+
},
40+
);

src/renderer.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ export function createRoot(options?: RootOptions): Root {
115115
options?.onCaughtError ?? defaultOnCaughtError,
116116
options?.onRecoverableError ?? defaultOnRecoverableError,
117117
defaultOnDefaultTransitionIndicator,
118-
null, // transitionCallbacks
119118
);
120119

121120
measureEnd("createRoot");

src/test-utils/react-version.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from "@jest/globals";
2+
import React from "react";
3+
4+
function isReactMinorOrNewer(targetMinor: number): boolean {
5+
const match = /^(\d+)\.(\d+)/.exec(React.version);
6+
7+
if (!match) {
8+
return false;
9+
}
10+
11+
const major = Number(match[1]);
12+
const minor = Number(match[2]);
13+
14+
if (major !== 19) {
15+
return major > 19;
16+
}
17+
18+
return minor >= targetMinor;
19+
}
20+
21+
export const testGateReact19_2 = isReactMinorOrNewer(2) ? test : test.skip;

0 commit comments

Comments
 (0)