Skip to content

feat: Add option to render concurrent roots #507

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
"cross-env": "^6.0.0",
"kcd-scripts": "^1.7.0",
"npm-run-all": "^4.1.5",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react": "^0.0.0-experimental-f6b8d31a7",
"react-dom": "^0.0.0-experimental-f6b8d31a7",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this resolves to the latest stable version. If we want to support the experimental release we have to do an exact version rather than ^. I believe that's why tests are failing.

"rimraf": "^3.0.0"
},
"peerDependencies": {
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/concurrent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import {act, render, cleanup} from '../'

test('simple render works like legacy', () => {
const {container} = render(<div>test</div>, {root: 'concurrent'})

expect(container).toHaveTextContent('test')
})

test('unmounts are flushed in sync', () => {
const {container, unmount} = render(<div>test</div>, {root: 'concurrent'})

unmount()

expect(container.children).toHaveLength(0)
})

test('rerender are flushed in sync', () => {
const {container, rerender} = render(<div>foo</div>, {root: 'concurrent'})

rerender(<div>bar</div>)

expect(container).toHaveTextContent('foo')
})

test('cleanup unmounts in sync', () => {
const {container} = render(<div>test</div>, {root: 'concurrent'})

cleanup()

expect(container.children).toHaveLength(0)
})

test('state updates are concurrent', () => {
function TrackingButton() {
const [clickCount, increment] = React.useReducer(n => n + 1, 0)

return (
<button type="button" onClick={increment}>
Clicked {clickCount} times.
</button>
)
}
const {getByRole} = render(<TrackingButton />, {root: 'concurrent'})

act(() => {
getByRole('button').click()
expect(getByRole('button')).toHaveTextContent('Clicked 0 times')
})

expect(getByRole('button')).toHaveTextContent('Clicked 1 times')
})
45 changes: 39 additions & 6 deletions src/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ configureDTL({
})

const mountedContainers = new Set()
/**
* @type {WeakMap<Element, ReactRoot>}
*/
const mountedRoots = new WeakMap()

function render(
ui,
Expand All @@ -28,6 +32,7 @@ function render(
queries,
hydrate = false,
wrapper: WrapperComponent,
root,
} = {},
) {
if (!baseElement) {
Expand All @@ -44,13 +49,21 @@ function render(
// they're passing us a custom container or not.
mountedContainers.add(container)

let reactRoot = null
if (root === 'concurrent') {
reactRoot = ReactDOM.createRoot(container, {hydrate})
mountedRoots.set(container, reactRoot)
}

const wrapUiIfNeeded = innerElement =>
WrapperComponent
? React.createElement(WrapperComponent, null, innerElement)
: innerElement

act(() => {
if (hydrate) {
if (reactRoot) {
reactRoot.render(wrapUiIfNeeded(ui))
} else if (hydrate) {
ReactDOM.hydrate(wrapUiIfNeeded(ui), container)
} else {
ReactDOM.render(wrapUiIfNeeded(ui), container)
Expand All @@ -66,11 +79,23 @@ function render(
el.forEach(e => console.log(prettyDOM(e)))
: // eslint-disable-next-line no-console,
console.log(prettyDOM(el)),
unmount: () => ReactDOM.unmountComponentAtNode(container),
unmount: () => {
if (reactRoot) {
act(() => {
reactRoot.unmount()
})
} else {
ReactDOM.unmountComponentAtNode(container)
}
},
rerender: rerenderUi => {
render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
// Intentionally do not return anything to avoid unnecessarily complicating the API.
// folks can use all the same utilities we return in the first place that are bound to the container
if (reactRoot === null) {
render(wrapUiIfNeeded(rerenderUi), {container, baseElement})
// Intentionally do not return anything to avoid unnecessarily complicating the API.
// folks can use all the same utilities we return in the first place that are bound to the container
} else {
reactRoot.render(wrapUiIfNeeded(rerenderUi))
}
},
asFragment: () => {
/* istanbul ignore if (jsdom limitation) */
Expand All @@ -95,7 +120,15 @@ function cleanup() {
// maybe one day we'll expose this (perhaps even as a utility returned by render).
// but let's wait until someone asks for it.
function cleanupAtContainer(container) {
ReactDOM.unmountComponentAtNode(container)
const reactRoot = mountedRoots.get(container)
if (reactRoot === undefined) {
ReactDOM.unmountComponentAtNode(container)
} else {
act(() => {
reactRoot.unmount()
})
}

if (container.parentNode === document.body) {
document.body.removeChild(container)
}
Expand Down
2 changes: 2 additions & 0 deletions tests/setup-env.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
import '@testing-library/jest-dom/extend-expect'
// eslint-disable-next-line import/no-extraneous-dependencies
jest.mock('scheduler', () => require('scheduler/unstable_mock'))
Copy link
Member Author

@eps1lon eps1lon Oct 24, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Biggest unknown to figure out. I think we need to use this to advance time (or something like that) if we have tests scheduling work (like setState).