Skip to content

Commit 77ba3da

Browse files
authored
React 18, take 2 (Shopify#191)
1 parent 96968d2 commit 77ba3da

File tree

10 files changed

+256
-120
lines changed

10 files changed

+256
-120
lines changed

.changeset/moody-gorillas-compare.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@remote-ui/react': major
3+
---
4+
5+
Added support for React 18 by having the consumer own the versions of `react` and `react-reconciler`. If you are currently using React 17 only, and are rendering in the “remote” context, you will need to add a dependency on `react-reconciler^0.27.0`. If you are using React 18, you will need to manually install the version of `react-reconciler` that matches up to that version (currently, `^0.29.0`).

.changeset/yellow-maps-own.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@remote-ui/react': major
3+
---
4+
5+
Removed re-export of `@remote-ui/rpc`. If you need `retain` or `release`, import them directly from `@remote-ui/rpc` instead.

packages/react/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
- @remote-ui/core@2.2.0
1919
- @remote-ui/rpc@1.4.0
2020

21+
### Deprecated
22+
23+
- The `render` function is deprecated. Please move to using the new `createRoot` instead, which matches the API provided by React.
24+
2125
## [4.5.1] - 2022-05-16
2226

2327
- Fixed a missing `useAttached()` export in the `@shopify/remote-ui/host` entrypoint.

packages/react/README.md

+40-11
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,45 @@ or, using `npm`:
1616
npm install @remote-ui/react --save
1717
```
1818

19+
### React peer dependencies
20+
21+
This package also has peer dependencies on a few React-related packages, but the versions you need depend on the version of React you are using:
22+
23+
**React 17.x.x**: you will need to have React installed. Additionally, if you are in the “remote” environment, you will need a dependency on `react-reconciler` between greater than or equal to `0.26.0`, and less than `0.28.0`:
24+
25+
```
26+
yarn add react@^17.0.0 react-reconciler@^0.27.0
27+
28+
# or, with `npm`:
29+
30+
npm install react@^17.0.0 react-reconciler@^0.27.0 --save
31+
```
32+
33+
**React 17.0.0 and later**: you will need to have React installed. Additionally, if you are in the “remote” environment, you will need a dependency on `react-reconciler` between greater than or equal to `0.28.0`:
34+
35+
```
36+
yarn add react react-reconciler
37+
38+
# or, with `npm`:
39+
40+
npm install react react-reconciler --save
41+
```
42+
43+
If you are only using the utilities for [React host applications](#host-environment), you do not need to declare a dependency on `react-reconciler`.
44+
1945
## Usage
2046

2147
### Remote environment
2248

23-
#### `render()`
49+
#### `createRoot()`
2450

25-
The main entrypoint for this package, `@remote-ui/react`, provides the custom React renderer that outputs instructions to a [`@remote-ui/core` `RemoteRoot`](../core#remoteroot) object. This lets you use the remote-ui system for communicating patch updates to host components over a bridge, but have React help manage your stateful application logic. To run a React app against a `RemoteRoot`, use the `render` function exported by this library, passing in the remote root and your root React component:
51+
The main entrypoint for this package, `@remote-ui/react`, provides the custom React renderer that outputs instructions to a [`@remote-ui/core` `RemoteRoot`](../core#remoteroot) object. This lets you use the remote-ui system for communicating patch updates to host components over a bridge, but have React help manage your stateful application logic.
52+
53+
To run a React app against a `RemoteRoot`, use the `createRoot` function exported by this library. This API has a similar signature to [the equivalent `react-dom` API](https://reactjs.org/docs/react-dom-client.html#createroot), where you first pass the the remote root you are targeting, and then render your React component into it:
2654

2755
```tsx
2856
// For convenience, this library re-exports several values from @remote-ui/core, like createRemoteRoot
29-
import {render, createRemoteRoot} from '@remote-ui/react';
57+
import {createRoot, createRemoteRoot} from '@remote-ui/react';
3058

3159
// a remote component — see implementation below for getting strong
3260
// typing on the available props.
@@ -43,16 +71,16 @@ function App() {
4371
return <Button onClick={() => console.log('clicked!')}>Click me!</Button>;
4472
}
4573

46-
render(<App />, remoteRoot);
74+
createRoot(remoteRoot).render(<App />);
4775
```
4876

4977
As you add, remove, and update host components in your React tree, this renderer will output those operations to the `RemoteRoot`. Since remote components are just a combination of a name and allowed properties, they map exactly to React components, which behave the same way.
5078

51-
Updating the the root React element for a given remote root can be done by calling the `render()` function again. For example, the root React element can be updated in an effect to receive updated props when they change:
79+
Updating the the root React element for a given remote root can be done by calling the `render()` method again. For example, the root React element can be updated in an effect to receive updated props when they change:
5280

5381
```tsx
5482
import {useEffect, useMemo} from 'react';
55-
import {render, createRemoteRoot} from '@remote-ui/react';
83+
import {createRoot, createRemoteRoot} from '@remote-ui/react';
5684

5785
// A remote component
5886
const Button = createRemoteReactComponent<'Button', {onPress(): void}>(
@@ -64,23 +92,24 @@ function App({count, onPress}: {count: number; onPress(): void}) {
6492
}
6593

6694
function MyRemoteRenderer() {
67-
const remoteRoot = useMemo(() => {
95+
const root = useMemo(() => {
6896
// Assuming we get a function that will communicate with the host...
6997
const channel = () => {};
7098

71-
return createRemoteRoot(channel, {
99+
const remoteRoot = createRemoteRoot(channel, {
72100
components: [Button],
73101
});
102+
103+
return createRoot(remoteRoot);
74104
}, []);
75105
const [count, setCount] = useState(0);
76106

77107
useEffect(() => {
78108
// We update the root component by calling `render` whenever `count` changes
79-
render(
109+
root.render(
80110
<App count={count} onPress={() => setCount((count) => count + 1)} />,
81-
remoteRoot,
82111
);
83-
}, [count, remoteRoot]);
112+
}, [count, root]);
84113
}
85114
```
86115

packages/react/package.json

+18-6
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,28 @@
3131
},
3232
"sideEffects": false,
3333
"devDependencies": {
34-
"@types/react-dom": "^17.0.0",
35-
"react": "^17.0.0",
36-
"react-dom": "^17.0.0"
34+
"@types/react-dom": "^18.0.5",
35+
"react": "^18.2.0",
36+
"react-dom": "^18.2.0",
37+
"react-reconciler": "^0.29.0"
3738
},
3839
"dependencies": {
3940
"@remote-ui/async-subscription": "^2.1.13",
4041
"@remote-ui/core": "^2.2.0",
4142
"@remote-ui/rpc": "^1.4.0",
42-
"@types/react": ">=17.0.0 <18.0.0",
43-
"@types/react-reconciler": "^0.26.0",
44-
"react-reconciler": ">=0.26.0 <0.27.0"
43+
"@types/react": ">=17.0.0 <19.0.0",
44+
"@types/react-reconciler": ">=0.26.0 <0.30.0"
45+
},
46+
"peerDependencies": {
47+
"react": ">=17.0.0 <19.0.0",
48+
"react-reconciler": ">=0.26.0 <0.30.0"
49+
},
50+
"peerDependenciesMeta": {
51+
"react": {
52+
"optional": false
53+
},
54+
"react-reconciler": {
55+
"optional": true
56+
}
4557
}
4658
}

packages/react/src/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
export {retain, release} from '@remote-ui/rpc';
21
export {createRemoteRoot} from '@remote-ui/core';
32
export type {RemoteRoot, RemoteReceiver} from '@remote-ui/core';
4-
export {render} from './render';
3+
export {render, createRoot} from './render';
4+
export type {Root} from './render';
55
export {createRemoteReactComponent} from './components';
66
export {useRemoteSubscription} from './hooks';
77
export type {

packages/react/src/reconciler.ts

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import reactReconciler, {Reconciler as ReactReconciler} from 'react-reconciler';
1+
import reactReconciler from 'react-reconciler';
2+
import type {Reconciler as ReactReconciler} from 'react-reconciler';
23
import type {
34
RemoteRoot,
45
RemoteText,
@@ -43,15 +44,26 @@ export const createReconciler = (options?: {primary?: boolean}) =>
4344
TimeoutHandle,
4445
NoTimeout
4546
>({
47+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
48+
// @ts-ignore - Compat for React <= 17.x
4649
now: Date.now,
4750

4851
// Timeout
4952
scheduleTimeout: setTimeout,
5053
cancelTimeout: clearTimeout,
5154
noTimeout: false,
52-
// @see https://github.com/facebook/react/blob/master/packages/react-dom/src/client/ReactDOMHostConfig.js#L408
53-
queueMicrotask: (callback) =>
54-
Promise.resolve(null).then(callback).catch(handleErrorInNextTick),
55+
56+
// Microtask scheduling
57+
// @see https://github.com/facebook/react/blob/2c8a1452b82b9ec5ebfa3f370b31fda19610ae92/packages/react-dom/src/client/ReactDOMHostConfig.js#L391-L401
58+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
59+
// @ts-ignore - types in `@types/react-reconciler` are outdated
60+
supportsMicrotasks: true,
61+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
62+
// @ts-ignore - types in `@types/react-reconciler` are outdated
63+
scheduleMicrotask,
64+
65+
// Compat for React <= 17.x
66+
queueMicrotask: scheduleMicrotask,
5567

5668
isPrimaryRenderer: options?.primary ?? true,
5769
supportsMutation: true,
@@ -171,6 +183,12 @@ export const createReconciler = (options?: {primary?: boolean}) =>
171183
preparePortalMount() {},
172184
});
173185

186+
function scheduleMicrotask(callback: () => void) {
187+
return typeof queueMicrotask === 'function'
188+
? queueMicrotask
189+
: Promise.resolve(null).then(callback).catch(handleErrorInNextTick);
190+
}
191+
174192
function handleErrorInNextTick(error: Error) {
175193
setTimeout(() => {
176194
throw error;

packages/react/src/render.tsx

+49-8
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type {ReactElement} from 'react';
1+
import {version} from 'react';
2+
import type {ReactNode} from 'react';
23
import type {RemoteRoot} from '@remote-ui/core';
34
import type {RootTag} from 'react-reconciler';
45

@@ -15,13 +16,34 @@ const cache = new WeakMap<
1516
}
1617
>();
1718

18-
// @see https://github.com/facebook/react/blob/993ca533b42756811731f6b7791ae06a35ee6b4d/packages/react-reconciler/src/ReactRootTags.js
19-
// I think we are a legacy root?
19+
// @see https://github.com/facebook/react/blob/fea6f8da6ab669469f2fa3f18bd3a831f00ab284/packages/react-reconciler/src/ReactRootTags.js#L12
20+
// We don't support concurrent rendering for now.
2021
const LEGACY_ROOT: RootTag = 0;
2122
const defaultReconciler = createReconciler();
2223

24+
export interface Root {
25+
render(children: ReactNode): void;
26+
unmount(): void;
27+
}
28+
29+
export function createRoot(root: RemoteRoot<any, any>): Root {
30+
return {
31+
render(children) {
32+
render(children, root);
33+
},
34+
unmount() {
35+
if (!cache.has(root)) return;
36+
render(null, root);
37+
cache.delete(root);
38+
},
39+
};
40+
}
41+
42+
/**
43+
* @deprecated Use `createRoot` for a React 18-style rendering API.
44+
*/
2345
export function render(
24-
element: ReactElement,
46+
element: ReactNode,
2547
root: RemoteRoot<any, any>,
2648
callback?: () => void,
2749
reconciler: Reconciler = defaultReconciler,
@@ -30,9 +52,26 @@ export function render(
3052
let cached = cache.get(root);
3153

3254
if (!cached) {
55+
const major = Number(version.split('.')?.[0] || 18);
56+
3357
// Since we haven't created a container for this root yet, create a new one
3458
const value = {
35-
container: reconciler.createContainer(root, LEGACY_ROOT, false, null),
59+
container:
60+
major >= 18
61+
? reconciler.createContainer(
62+
root,
63+
LEGACY_ROOT,
64+
null,
65+
false,
66+
null,
67+
// Might not be necessary
68+
'r-ui',
69+
() => null,
70+
null,
71+
)
72+
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
73+
// @ts-ignore - this is to support React 17
74+
reconciler.createContainer(root, LEGACY_ROOT, false, null),
3675
// We also cache the render context to avoid re-creating it on subsequent render calls
3776
renderContext: {root, reconciler},
3877
};
@@ -47,9 +86,11 @@ export function render(
4786
// callback is cast here because the typings do not mark that argument
4887
// as optional, even though it is.
4988
reconciler.updateContainer(
50-
<RenderContext.Provider value={renderContext}>
51-
{element}
52-
</RenderContext.Provider>,
89+
element && (
90+
<RenderContext.Provider value={renderContext}>
91+
{element}
92+
</RenderContext.Provider>
93+
),
5394
container,
5495
null,
5596
callback as any,

0 commit comments

Comments
 (0)