Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Commit

Permalink
[react-testing] fixed react queue emptying for React 17
Browse files Browse the repository at this point in the history
  • Loading branch information
melnikov-s committed Oct 17, 2022
1 parent d81ecbe commit 27067e1
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/great-comics-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/react-testing': patch
---

Fix act queue emptying for React 17
30 changes: 30 additions & 0 deletions packages/react-testing/src/compat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ReactDOM from 'react-dom';
import React from 'react';
import type {Root as ReactRoot} from 'react-dom/client';

import {ReactInstance, Fiber} from './types';
Expand Down Expand Up @@ -34,3 +35,32 @@ export function createRoot(element: HTMLElement): ReactRoot {
return createRootShim(element);
}
}

export const isLegacyReact = parseInt(React.version, 10) < 18;

// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/shared/enqueueTask.js#L13
let enqueueTaskImpl: any = null;

export function enqueueTask(task: (v: any) => void) {
if (enqueueTaskImpl === null) {
try {
// read require off the module object to get around the bundlers.
// we don't want them to detect a require and bundle a Node polyfill.
const requireString = `require${Math.random()}`.slice(0, 7);
const nodeRequire = module && module[requireString];
// assuming we're in node, let's try to get node's
// version of setImmediate, bypassing fake timers if any.
enqueueTaskImpl = nodeRequire.call(module, 'timers').setImmediate;
} catch (_err) {
// we're in a browser
// we can't use regular timers because they may still be faked
// so we try MessageChannel+postMessage instead
enqueueTaskImpl = function (callback: () => void) {
const channel = new MessageChannel();
channel.port1.onmessage = callback;
channel.port2.postMessage(undefined);
};
}
}
return enqueueTaskImpl!(task);
}
19 changes: 15 additions & 4 deletions packages/react-testing/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {act} from 'react-dom/test-utils';

import {TestWrapper} from './TestWrapper';
import {Element} from './element';
import {createRoot, getInternals} from './compat';
import {createRoot, getInternals, enqueueTask, isLegacyReact} from './compat';
import {
Tag,
Fiber,
Expand Down Expand Up @@ -76,6 +76,7 @@ export class Root<Props> implements Node<Props> {
private acting = false;
private destroyed = false;
private actPromise!: Promise<void>;
private actPromises: Promise<void>[] = [];
private actPromiseResolver!: () => void;

private render: Render;
Expand Down Expand Up @@ -140,10 +141,14 @@ export class Root<Props> implements Node<Props> {
return result as unknown as Promise<void>;
}

return Promise.race([
const promise = Promise.race([
result,
this.actPromise,
]) as unknown as Promise<void>;

this.actPromises.push(promise);

return promise;
}

return undefined as unknown as Promise<void>;
Expand Down Expand Up @@ -287,8 +292,14 @@ export class Root<Props> implements Node<Props> {
connected.delete(this);
this.destroyed = true;
this.actPromiseResolver();
// flush the micro task to wait until react commits all pending updates.
await this.actPromise;

// flush all micro tasks to wait until react commits all pending updates.
await Promise.all(this.actPromises);

if (isLegacyReact) {
// flush macro task for react 17 only
await new Promise((resolve) => enqueueTask(resolve));
}
}

setProps(props: Partial<Props>) {
Expand Down
47 changes: 19 additions & 28 deletions packages/react-testing/src/tests/e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import {createPortal} from 'react-dom';

import {mount, createMount} from '../mount';

const itIf = (condition) => (condition ? it : it.skip);

describe('@shopify/react-testing', () => {
it('does not time out with large trees', () => {
function RecurseMyself({times}: {times: number}) {
Expand Down Expand Up @@ -175,35 +173,28 @@ describe('@shopify/react-testing', () => {
);
}

// eslint-disable-next-line no-process-env
itIf(process.env.REACT_VERSION !== '17')(
'releases any stale promises when component is destroyed',
async () => {
const wrapper = mount(<Counter />);
wrapper.act(() => new Promise(() => {}));
wrapper.act(() => new Promise(() => {}));
await wrapper.destroy();

// React 17 will fail without this await
await new Promise((resolve) => setTimeout(resolve, 0));
it('releases any stale promises when component is destroyed', async () => {
const wrapper = mount(<Counter />);
wrapper.act(() => new Promise(() => {}));
wrapper.act(() => new Promise(() => {}));
await wrapper.destroy();

function EffectChangeComponent({children}: {children?: ReactNode}) {
const [counter, setCounter] = useState(0);
useEffect(() => setCounter(100), []);
function EffectChangeComponent({children}: {children?: ReactNode}) {
const [counter, setCounter] = useState(0);
useEffect(() => setCounter(100), []);

return (
// eslint-disable-next-line @shopify/jsx-prefer-fragment-wrappers
<div>
<Message>{counter}</Message>
{children}
</div>
);
}
const newWrapper = mount(<EffectChangeComponent />);
return (
// eslint-disable-next-line @shopify/jsx-prefer-fragment-wrappers
<div>
<Message>{counter}</Message>
{children}
</div>
);
}
const newWrapper = mount(<EffectChangeComponent />);

expect(newWrapper.find(Message)!.html()).toBe('<span>100</span>');
},
);
expect(newWrapper.find(Message)!.html()).toBe('<span>100</span>');
});

it('updates element tree when state is changed', () => {
const wrapper = mount(<Counter />);
Expand Down

0 comments on commit 27067e1

Please sign in to comment.