Skip to content

Commit b9e9466

Browse files
authored
React 19 support (#719)
1 parent 991aaa5 commit b9e9466

File tree

9 files changed

+92
-48
lines changed

9 files changed

+92
-48
lines changed

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"indent-string": "^5.0.0",
5757
"is-in-ci": "^1.0.0",
5858
"patch-console": "^2.0.0",
59-
"react-reconciler": "^0.29.0",
59+
"react-reconciler": "^0.32.0",
6060
"scheduler": "^0.23.0",
6161
"signal-exit": "^3.0.7",
6262
"slice-ansi": "^7.1.0",
@@ -74,8 +74,8 @@
7474
"@types/benchmark": "^2.1.2",
7575
"@types/ms": "^0.7.31",
7676
"@types/node": "^22.9.0",
77-
"@types/react": "^18.3.12",
78-
"@types/react-reconciler": "^0.28.2",
77+
"@types/react": "^19.1.5",
78+
"@types/react-reconciler": "^0.32.0",
7979
"@types/scheduler": "^0.23.0",
8080
"@types/signal-exit": "^3.0.0",
8181
"@types/sinon": "^17.0.3",
@@ -92,7 +92,7 @@
9292
"node-pty": "^1.0.0",
9393
"p-queue": "^8.0.0",
9494
"prettier": "^3.3.3",
95-
"react": "^18.0.0",
95+
"react": "^19.1.0",
9696
"react-devtools-core": "^5.0.0",
9797
"sinon": "^19.0.2",
9898
"strip-ansi": "^7.1.0",
@@ -101,8 +101,8 @@
101101
"xo": "^0.59.3"
102102
},
103103
"peerDependencies": {
104-
"@types/react": ">=18.0.0",
105-
"react": ">=18.0.0",
104+
"@types/react": ">=19.0.0",
105+
"react": ">=19.0.0",
106106
"react-devtools-core": "^4.19.1"
107107
},
108108
"peerDependenciesMeta": {

src/components/Box.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
1414
<ink-box
1515
ref={ref}
1616
style={{
17+
flexWrap: 'nowrap',
18+
flexDirection: 'row',
19+
flexGrow: 0,
20+
flexShrink: 1,
1721
...style,
1822
overflowX: style.overflowX ?? style.overflow ?? 'visible',
1923
overflowY: style.overflowY ?? style.overflow ?? 'visible',
@@ -27,11 +31,4 @@ const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
2731

2832
Box.displayName = 'Box';
2933

30-
Box.defaultProps = {
31-
flexWrap: 'nowrap',
32-
flexDirection: 'row',
33-
flexGrow: 0,
34-
flexShrink: 1,
35-
};
36-
3734
export default Box;

src/devtools-window-polyfill.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ws from 'ws';
55
const customGlobal = global as any;
66

77
// These things must exist before importing `react-devtools-core`
8+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
89
customGlobal.WebSocket ||= ws;
910

1011
customGlobal.window ||= global;

src/global.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {type Except} from 'type-fest';
33
import {type DOMElement} from './dom.js';
44
import {type Styles} from './styles.js';
55

6-
declare global {
6+
declare module 'react' {
77
namespace JSX {
88
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
99
interface IntrinsicElements {

src/ink.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import isInCi from 'is-in-ci';
66
import autoBind from 'auto-bind';
77
import signalExit from 'signal-exit';
88
import patchConsole from 'patch-console';
9+
import {LegacyRoot} from 'react-reconciler/constants.js';
910
import {type FiberRoot} from 'react-reconciler';
1011
import Yoga from 'yoga-layout';
1112
import reconciler from './reconciler.js';
@@ -79,13 +80,17 @@ export default class Ink {
7980
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
8081
this.container = reconciler.createContainer(
8182
this.rootNode,
82-
// Legacy mode
83-
0,
83+
LegacyRoot,
8484
null,
8585
false,
8686
null,
8787
'id',
8888
() => {},
89+
() => {},
90+
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
91+
// See https://github.com/facebook/react/blob/c0464aedb16b1c970d717651bba8d1c66c578729/packages/react-reconciler/src/ReactFiberReconciler.js#L236-L259
92+
() => {},
93+
() => {},
8994
null,
9095
);
9196

@@ -207,7 +212,12 @@ export default class Ink {
207212
</App>
208213
);
209214

210-
reconciler.updateContainer(tree, this.container, null, noop);
215+
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
216+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
217+
reconciler.updateContainerSync(tree, this.container, null, noop);
218+
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
219+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
220+
reconciler.flushSyncWork();
211221
}
212222

213223
writeToStdout(data: string): void {
@@ -279,7 +289,12 @@ export default class Ink {
279289

280290
this.isUnmounted = true;
281291

282-
reconciler.updateContainer(null, this.container, null, noop);
292+
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
293+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
294+
reconciler.updateContainerSync(null, this.container, null, noop);
295+
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
296+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
297+
reconciler.flushSyncWork();
283298
instances.delete(this.options.stdout);
284299

285300
if (error instanceof Error) {

src/reconciler.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import process from 'node:process';
2-
import createReconciler from 'react-reconciler';
3-
import {DefaultEventPriority} from 'react-reconciler/constants.js';
2+
import createReconciler, {type ReactContext} from 'react-reconciler';
3+
import {
4+
DefaultEventPriority,
5+
NoEventPriority,
6+
} from 'react-reconciler/constants.js';
47
import Yoga, {type Node as YogaNode} from 'yoga-layout';
8+
import {createContext} from 'react';
59
import {
610
createTextNode,
711
appendChildNode,
@@ -90,10 +94,7 @@ type HostContext = {
9094
isInsideText: boolean;
9195
};
9296

93-
type UpdatePayload = {
94-
props: Props | undefined;
95-
style: Styles | undefined;
96-
};
97+
let currentUpdatePriority = NoEventPriority;
9798

9899
export default createReconciler<
99100
ElementNames,
@@ -104,8 +105,9 @@ export default createReconciler<
104105
DOMElement,
105106
unknown,
106107
unknown,
108+
unknown,
107109
HostContext,
108-
UpdatePayload,
110+
unknown,
109111
unknown,
110112
unknown,
111113
unknown
@@ -148,7 +150,7 @@ export default createReconciler<
148150
return {isInsideText};
149151
},
150152
shouldSetTextContent: () => false,
151-
createInstance(originalType, newProps, _root, hostContext) {
153+
createInstance(originalType, newProps, rootNode, hostContext) {
152154
if (hostContext.isInsideText && originalType === 'ink-box') {
153155
throw new Error(`<Box> can’t be nested inside <Text> component`);
154156
}
@@ -182,6 +184,11 @@ export default createReconciler<
182184

183185
if (key === 'internal_static') {
184186
node.internal_static = true;
187+
rootNode.isStaticDirty = true;
188+
189+
// Save reference to <Static> node to skip traversal of entire
190+
// node tree to find it
191+
rootNode.staticNode = node;
185192
continue;
186193
}
187194

@@ -216,15 +223,7 @@ export default createReconciler<
216223
appendInitialChild: appendChildNode,
217224
appendChild: appendChildNode,
218225
insertBefore: insertBeforeNode,
219-
finalizeInitialChildren(node, _type, _props, rootNode) {
220-
if (node.internal_static) {
221-
rootNode.isStaticDirty = true;
222-
223-
// Save reference to <Static> node to skip traversal of entire
224-
// node tree to find it
225-
rootNode.staticNode = node;
226-
}
227-
226+
finalizeInitialChildren() {
228227
return false;
229228
},
230229
isPrimaryRenderer: true,
@@ -234,7 +233,6 @@ export default createReconciler<
234233
scheduleTimeout: setTimeout,
235234
cancelTimeout: clearTimeout,
236235
noTimeout: -1,
237-
getCurrentEventPriority: () => DefaultEventPriority,
238236
beforeActiveInstanceBlur() {},
239237
afterActiveInstanceBlur() {},
240238
detachDeletedInstance() {},
@@ -247,11 +245,7 @@ export default createReconciler<
247245
removeChildNode(node, removeNode);
248246
cleanupYogaNode(removeNode.yogaNode);
249247
},
250-
prepareUpdate(node, _type, oldProps, newProps, rootNode) {
251-
if (node.internal_static) {
252-
rootNode.isStaticDirty = true;
253-
}
254-
248+
commitUpdate(node, _type, oldProps, newProps, _root) {
255249
const props = diff(oldProps, newProps);
256250

257251
const style = diff(
@@ -260,12 +254,9 @@ export default createReconciler<
260254
);
261255

262256
if (!props && !style) {
263-
return null;
257+
return;
264258
}
265259

266-
return {props, style};
267-
},
268-
commitUpdate(node, {props, style}) {
269260
if (props) {
270261
for (const [key, value] of Object.entries(props)) {
271262
if (key === 'style') {
@@ -298,4 +289,44 @@ export default createReconciler<
298289
removeChildNode(node, removeNode);
299290
cleanupYogaNode(removeNode.yogaNode);
300291
},
292+
setCurrentUpdatePriority(newPriority: number) {
293+
currentUpdatePriority = newPriority;
294+
},
295+
getCurrentUpdatePriority: () => currentUpdatePriority,
296+
resolveUpdatePriority() {
297+
if (currentUpdatePriority !== NoEventPriority) {
298+
return currentUpdatePriority;
299+
}
300+
301+
return DefaultEventPriority;
302+
},
303+
maySuspendCommit() {
304+
return false;
305+
},
306+
// eslint-disable-next-line @typescript-eslint/naming-convention
307+
NotPendingTransition: undefined,
308+
// eslint-disable-next-line @typescript-eslint/naming-convention
309+
HostTransitionContext: createContext(
310+
null,
311+
) as unknown as ReactContext<unknown>,
312+
resetFormInstance() {},
313+
requestPostPaintCallback() {},
314+
shouldAttemptEagerTransition() {
315+
return false;
316+
},
317+
trackSchedulerEvent() {},
318+
resolveEventType() {
319+
return null;
320+
},
321+
resolveEventTimeStamp() {
322+
return -1.1;
323+
},
324+
preloadInstance() {
325+
return true;
326+
},
327+
startSuspendingCommit() {},
328+
suspendInstance() {},
329+
waitForCommitToBeReady() {
330+
return null;
331+
},
301332
});

test/focus.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ test('switch focus to the last component if currently focused component is the f
283283

284284
await delay(100);
285285
emitReadable(stdin, '\u001B[Z');
286+
await delay(100);
286287

287288
t.is(
288289
(stdout.write as any).lastCall.args[0],

test/helpers/render-to-string.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {render} from '../../src/index.js';
22
import createStdout from './create-stdout.js';
33

44
export const renderToString: (
5-
node: JSX.Element,
5+
node: React.JSX.Element,
66
options?: {columns: number},
77
) => string = (node, options) => {
88
const stdout = createStdout(options?.columns ?? 100);

tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
],
1010
"sourceMap": true,
1111
"jsx": "react",
12-
"isolatedModules": true,
13-
"lib": ["ES2023"]
12+
"isolatedModules": true
1413
},
1514
"include": ["src"],
1615
"ts-node": {

0 commit comments

Comments
 (0)