Skip to content

Commit

Permalink
DevTools flushes updated passive warning/error info after delay (#20931)
Browse files Browse the repository at this point in the history
* DevTools flushes updated passive warning/error info after delay
Previously this information was not flushed until the next commit, but this provides a worse user experience if the next commit is really delayed. Instead, the backend now flushes only the warning/error counts after a delay. As a safety, if there are already any pending operations in the queue, we bail.

Co-authored-by: eps1lon <silbermann.sebastian@gmail.com>
  • Loading branch information
Brian Vaughn and eps1lon authored Mar 8, 2021
1 parent cb88572 commit e7d2a55
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 49 deletions.
126 changes: 98 additions & 28 deletions packages/react-devtools-shared/src/__tests__/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1075,40 +1075,110 @@ describe('Store', () => {
`);
});

// This is not great, but it seems safer than potentially flushing between commits.
// Our logic for determining how to handle e.g. suspended trees or error boundaries
// is built on the assumption that we're evaluating the results of a commit, not an in-progress render.
it('during passive get counted (but not until the next commit)', () => {
function Example() {
React.useEffect(() => {
console.error('test-only: passive error');
console.warn('test-only: passive warning');
});
return null;
describe('during passive effects', () => {
function flushPendingBridgeOperations() {
jest.runOnlyPendingTimers();
}
const container = document.createElement('div');

withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example />, container));
});

expect(store).toMatchInlineSnapshot(`
[root]
<Example>
`);
// Gross abstraction around pending passive warning/error delay.
function flushPendingPassiveErrorAndWarningCounts() {
jest.advanceTimersByTime(1000);
}

withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => ReactDOM.render(<Example rerender={1} />, container));
it('are counted (after a delay)', () => {
function Example() {
React.useEffect(() => {
console.error('test-only: passive error');
console.warn('test-only: passive warning');
});
return null;
}
const container = document.createElement('div');

withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => {
ReactDOM.render(<Example />, container);
}, false);
});
flushPendingBridgeOperations();
expect(store).toMatchInlineSnapshot(`
[root]
<Example>
`);

// After a delay, passive effects should be committed as well
act(flushPendingPassiveErrorAndWarningCounts, false);
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
`);

act(() => ReactDOM.unmountComponentAtNode(container));
expect(store).toMatchInlineSnapshot(``);
});

expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
`);
it('are flushed early when there is a new commit', () => {
function Example() {
React.useEffect(() => {
console.error('test-only: passive error');
console.warn('test-only: passive warning');
});
return null;
}

function Noop() {
return null;
}

const container = document.createElement('div');

withErrorsOrWarningsIgnored(['test-only:'], () => {
act(() => {
ReactDOM.render(
<>
<Example />
</>,
container,
);
}, false);
flushPendingBridgeOperations();
expect(store).toMatchInlineSnapshot(`
[root]
<Example>
`);

// Before warnings and errors have flushed, flush another commit.
act(() => {
ReactDOM.render(
<>
<Example />
<Noop />
</>,
container,
);
}, false);
flushPendingBridgeOperations();
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 1
[root]
<Example> ✕⚠
<Noop>
`);
});

act(() => ReactDOM.unmountComponentAtNode(container));
expect(store).toMatchInlineSnapshot(``);
// After a delay, passive effects should be committed as well
act(flushPendingPassiveErrorAndWarningCounts, false);
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 2
[root]
<Example> ✕⚠
<Noop>
`);

act(() => ReactDOM.unmountComponentAtNode(container));
expect(store).toMatchInlineSnapshot(``);
});
});

it('from react get counted', () => {
Expand Down
19 changes: 12 additions & 7 deletions packages/react-devtools-shared/src/__tests__/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import type Store from 'react-devtools-shared/src/devtools/store';
import type {ProfilingDataFrontend} from 'react-devtools-shared/src/devtools/views/Profiler/types';
import type {ElementType} from 'react-devtools-shared/src/types';

export function act(callback: Function): void {
export function act(
callback: Function,
recursivelyFlush: boolean = true,
): void {
const {act: actTestRenderer} = require('react-test-renderer');
const {act: actDOM} = require('react-dom/test-utils');

Expand All @@ -24,13 +27,15 @@ export function act(callback: Function): void {
});
});

// Flush Bridge operations
while (jest.getTimerCount() > 0) {
actDOM(() => {
actTestRenderer(() => {
jest.runAllTimers();
if (recursivelyFlush) {
// Flush Bridge operations
while (jest.getTimerCount() > 0) {
actDOM(() => {
actTestRenderer(() => {
jest.runAllTimers();
});
});
});
}
}
}

Expand Down
86 changes: 72 additions & 14 deletions packages/react-devtools-shared/src/backend/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,14 @@ export function attach(

// If this Fiber is currently being inspected, mark it as needing an udpate as well.
updateMostRecentlyInspectedElementIfNecessary(fiberID);

// Passive effects may trigger errors or warnings too;
// In this case, we should wait until the rest of the passive effects have run,
// but we shouldn't wait until the next commit because that might be a long time.
// This would also cause "tearing" between an inspected Component and the tree view.
// Then again we don't want to flush too soon because this could be an error during async rendering.
// Use a debounce technique to ensure that we'll eventually flush.
flushPendingErrorsAndWarningsAfterDelay();
}

// Patching the console enables DevTools to do a few useful things:
Expand Down Expand Up @@ -1198,10 +1206,12 @@ export function attach(
}
}

const pendingOperations: Array<number> = [];
type OperationsArray = Array<number>;

const pendingOperations: OperationsArray = [];
const pendingRealUnmountedIDs: Array<number> = [];
const pendingSimulatedUnmountedIDs: Array<number> = [];
let pendingOperationsQueue: Array<Array<number>> | null = [];
let pendingOperationsQueue: Array<OperationsArray> | null = [];
const pendingStringTable: Map<string, number> = new Map();
let pendingStringTableLength: number = 0;
let pendingUnmountedRootID: number | null = null;
Expand All @@ -1218,6 +1228,61 @@ export function attach(
pendingOperations.push(op);
}

function flushOrQueueOperations(operations: OperationsArray): void {
if (pendingOperationsQueue !== null) {
pendingOperationsQueue.push(operations);
} else {
hook.emit('operations', operations);
}
}

let flushPendingErrorsAndWarningsAfterDelayTimeoutID = null;

function clearPendingErrorsAndWarningsAfterDelay() {
if (flushPendingErrorsAndWarningsAfterDelayTimeoutID !== null) {
clearTimeout(flushPendingErrorsAndWarningsAfterDelayTimeoutID);
flushPendingErrorsAndWarningsAfterDelayTimeoutID = null;
}
}

function flushPendingErrorsAndWarningsAfterDelay() {
clearPendingErrorsAndWarningsAfterDelay();

flushPendingErrorsAndWarningsAfterDelayTimeoutID = setTimeout(() => {
flushPendingErrorsAndWarningsAfterDelayTimeoutID = null;

if (pendingOperations.length > 0) {
// On the off chance that something else has pushed pending operations,
// we should bail on warnings; it's probably not safe to push midway.
return;
}

recordPendingErrorsAndWarnings();

if (pendingOperations.length === 0) {
// No warnings or errors to flush; we can bail out early here too.
return;
}

// We can create a smaller operations array than flushPendingEvents()
// because we only need to flush warning and error counts.
// Only a few pieces of fixed information are required up front.
const operations: OperationsArray = new Array(
3 + pendingOperations.length,
);
operations[0] = rendererID;
operations[1] = currentRootID;
operations[2] = 0; // String table size
for (let j = 0; j < pendingOperations.length; j++) {
operations[3 + j] = pendingOperations[j];
}

flushOrQueueOperations(operations);

pendingOperations.length = 0;
}, 1000);
}

function reevaluateErrorsAndWarnings() {
fibersWithChangedErrorOrWarningCounts.clear();
fiberToErrorsMap.forEach((countMap, fiberID) => {
Expand All @@ -1230,6 +1295,8 @@ export function attach(
}

function recordPendingErrorsAndWarnings() {
clearPendingErrorsAndWarningsAfterDelay();

fibersWithChangedErrorOrWarningCounts.forEach(fiberID => {
const fiber = idToFiberMap.get(fiberID);
if (fiber != null) {
Expand Down Expand Up @@ -1319,7 +1386,7 @@ export function attach(
// Which in turn enables fiber props, states, and hooks to be inspected.
let i = 0;
operations[i++] = rendererID;
operations[i++] = currentRootID; // Use this ID in case the root was unmounted!
operations[i++] = currentRootID;

// Now fill in the string table.
// [stringTableLength, str1Length, ...str1, str2Length, ...str2, ...]
Expand Down Expand Up @@ -1366,18 +1433,9 @@ export function attach(
i += pendingOperations.length;

// Let the frontend know about tree operations.
// The first value in this array will identify which root it corresponds to,
// so we do no longer need to dispatch a separate root-committed event.
if (pendingOperationsQueue !== null) {
// Until the frontend has been connected, store the tree operations.
// This will let us avoid walking the tree later when the frontend connects,
// and it enables the Profiler's reload-and-profile functionality to work as well.
pendingOperationsQueue.push(operations);
} else {
// If we've already connected to the frontend, just pass the operations through.
hook.emit('operations', operations);
}
flushOrQueueOperations(operations);

// Reset all of the pending state now that we've told the frontend about it.
pendingOperations.length = 0;
pendingRealUnmountedIDs.length = 0;
pendingSimulatedUnmountedIDs.length = 0;
Expand Down

0 comments on commit e7d2a55

Please sign in to comment.