mirror of
https://github.com/facebook/react.git
synced 2026-02-26 18:58:05 +00:00
There's two problems. The biggest one is that it turns out that Chrome is throttling looping timers that we're using both while polling and for batching bridge traffic. This means that bridge traffic a lot of the time just slows down to 1 second at a time. No wonder it feels sluggish. The only solution is to not use timers for this. Even when it doesn't like in Firefox the batching into 100ms still feels too sluggish. The fix I use is to batch using a microtask instead so we can still batch multiple commands sent in a single event but we never artificially slow down an interaction. I don't think we've reevaluated this for a long time since this was in the initial commit of DevTools to this repo. If it causes other issues we can follow up on those. We really shouldn't use timers for debouncing and such. In fact, React itself recommends against it because we have a better technique with scheduling in Concurrent Mode. The correct way to implement this in the bridge is using a form of back-pressure where we don't keep sending messages until we get a message back and only send the last one that matters. E.g. when moving the cursor over a the elements tab we shouldn't let the backend one-by-one move the DOM node to each one we have ever passed. We should just move to the last one we're currently hovering over. But this can't be done at the bridge layer since it doesn't know if it's a last-one-wins or imperative operation where each one needs to be sent. It needs to be done higher. I'm not currently seeing any perf problems with this new approach but I'm curious on React Native or some thing. RN might need the back-pressure approach. That can be a follow up if we ever find a test case. Finally, the other problem is that we use a Suspense boundary around the Element Inspection. Suspense boundaries are for things that are expected to take a long time to load. This shows a loading state immediately. To avoid flashing when it ends up being fast, React throttles the reveal to 200ms. This means that we take a minimum of 200ms to show the props. The way to show fast async data in React is using a Transition (either using startTransition or useDeferredValue). This lets the old value remaining in place while we're loading the next one. We already implement this using `inspectedElementID` which is the async one. It would be more idiomatic to implement this with useDeferredValue rather than the reducer we have now but same principle. We were just using the wrong ID in a few places so when it synchronously updated they suspended. So I just made them use the inspectedElementID instead. Then I can simply remove the Suspense boundary. Now the selection updates in the tree view synchronously and the sidebar lags a frame or two but it feels instant. It doesn't flash to white between which is key.
255 lines
7.9 KiB
JavaScript
255 lines
7.9 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import {CustomConsole} from '@jest/console';
|
|
|
|
import type {
|
|
BackendBridge,
|
|
FrontendBridge,
|
|
} from 'react-devtools-shared/src/bridge';
|
|
const {getTestFlags} = require('../../../../scripts/jest/TestFlags');
|
|
|
|
// Argument is serialized when passed from jest-cli script through to setupTests.
|
|
const compactConsole = process.env.compactConsole === 'true';
|
|
if (compactConsole) {
|
|
const formatter = (type, message) => {
|
|
switch (type) {
|
|
case 'error':
|
|
return '\x1b[31m' + message + '\x1b[0m';
|
|
case 'warn':
|
|
return '\x1b[33m' + message + '\x1b[0m';
|
|
case 'log':
|
|
default:
|
|
return message;
|
|
}
|
|
};
|
|
|
|
global.console = new CustomConsole(process.stdout, process.stderr, formatter);
|
|
}
|
|
|
|
const expectTestToFail = async (callback, error) => {
|
|
if (callback.length > 0) {
|
|
throw Error(
|
|
'Gated test helpers do not support the `done` callback. Return a ' +
|
|
'promise instead.',
|
|
);
|
|
}
|
|
try {
|
|
const maybePromise = callback();
|
|
if (
|
|
maybePromise !== undefined &&
|
|
maybePromise !== null &&
|
|
typeof maybePromise.then === 'function'
|
|
) {
|
|
await maybePromise;
|
|
}
|
|
} catch (testError) {
|
|
return;
|
|
}
|
|
throw error;
|
|
};
|
|
|
|
const gatedErrorMessage = 'Gated test was expected to fail, but it passed.';
|
|
global._test_gate = (gateFn, testName, callback) => {
|
|
let shouldPass;
|
|
try {
|
|
const flags = getTestFlags();
|
|
shouldPass = gateFn(flags);
|
|
} catch (e) {
|
|
test(testName, () => {
|
|
throw e;
|
|
});
|
|
return;
|
|
}
|
|
if (shouldPass) {
|
|
test(testName, callback);
|
|
} else {
|
|
const error = new Error(gatedErrorMessage);
|
|
Error.captureStackTrace(error, global._test_gate);
|
|
test(`[GATED, SHOULD FAIL] ${testName}`, () =>
|
|
expectTestToFail(callback, error));
|
|
}
|
|
};
|
|
global._test_gate_focus = (gateFn, testName, callback) => {
|
|
let shouldPass;
|
|
try {
|
|
const flags = getTestFlags();
|
|
shouldPass = gateFn(flags);
|
|
} catch (e) {
|
|
test.only(testName, () => {
|
|
throw e;
|
|
});
|
|
return;
|
|
}
|
|
if (shouldPass) {
|
|
test.only(testName, callback);
|
|
} else {
|
|
const error = new Error(gatedErrorMessage);
|
|
Error.captureStackTrace(error, global._test_gate_focus);
|
|
test.only(`[GATED, SHOULD FAIL] ${testName}`, () =>
|
|
expectTestToFail(callback, error));
|
|
}
|
|
};
|
|
|
|
// Dynamic version of @gate pragma
|
|
global.gate = fn => {
|
|
const flags = getTestFlags();
|
|
return fn(flags);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
global.mockClipboardCopy = jest.fn();
|
|
|
|
// Test environment doesn't support document methods like execCommand()
|
|
// Also once the backend components below have been required,
|
|
// it's too late for a test to mock the clipboard-js modules.
|
|
jest.mock('clipboard-js', () => ({copy: global.mockClipboardCopy}));
|
|
|
|
// These files should be required (and re-required) before each test,
|
|
// rather than imported at the head of the module.
|
|
// That's because we reset modules between tests,
|
|
// which disconnects the DevTool's cache from the current dispatcher ref.
|
|
const Agent = require('react-devtools-shared/src/backend/agent').default;
|
|
const {initBackend} = require('react-devtools-shared/src/backend');
|
|
const Bridge = require('react-devtools-shared/src/bridge').default;
|
|
const Store = require('react-devtools-shared/src/devtools/store').default;
|
|
const {installHook} = require('react-devtools-shared/src/hook');
|
|
const {
|
|
getDefaultComponentFilters,
|
|
setSavedComponentFilters,
|
|
} = require('react-devtools-shared/src/utils');
|
|
|
|
// Fake timers let us flush Bridge operations between setup and assertions.
|
|
jest.useFakeTimers();
|
|
|
|
// We use fake timers heavily in tests but the bridge batching now uses microtasks.
|
|
global.devtoolsJestTestScheduler = callback => {
|
|
setTimeout(callback, 0);
|
|
};
|
|
|
|
// Use utils.js#withErrorsOrWarningsIgnored instead of directly mutating this array.
|
|
global._ignoredErrorOrWarningMessages = [
|
|
'react-test-renderer is deprecated.',
|
|
];
|
|
function shouldIgnoreConsoleErrorOrWarn(args) {
|
|
let firstArg = args[0];
|
|
if (
|
|
firstArg !== null &&
|
|
typeof firstArg === 'object' &&
|
|
String(firstArg).indexOf('Error: Uncaught [') === 0
|
|
) {
|
|
firstArg = String(firstArg);
|
|
} else if (typeof firstArg !== 'string') {
|
|
return false;
|
|
}
|
|
const shouldFilter = global._ignoredErrorOrWarningMessages.some(
|
|
errorOrWarningMessage => {
|
|
return firstArg.indexOf(errorOrWarningMessage) !== -1;
|
|
},
|
|
);
|
|
|
|
return shouldFilter;
|
|
}
|
|
|
|
const originalConsoleError = console.error;
|
|
console.error = (...args) => {
|
|
let firstArg = args[0];
|
|
if (typeof firstArg === 'string' && firstArg.startsWith('Warning: ')) {
|
|
// Older React versions might use the Warning: prefix. I'm not sure
|
|
// if they use this code path.
|
|
firstArg = firstArg.slice(9);
|
|
}
|
|
if (firstArg === 'React instrumentation encountered an error: %s') {
|
|
// Rethrow errors from React.
|
|
throw args[1];
|
|
} else if (
|
|
typeof firstArg === 'string' &&
|
|
(firstArg.startsWith("It looks like you're using the wrong act()") ||
|
|
firstArg.startsWith(
|
|
'The current testing environment is not configured to support act',
|
|
) ||
|
|
firstArg.startsWith('You seem to have overlapping act() calls'))
|
|
) {
|
|
// DevTools intentionally wraps updates with acts from both DOM and test-renderer,
|
|
// since test updates are expected to impact both renderers.
|
|
return;
|
|
} else if (shouldIgnoreConsoleErrorOrWarn(args)) {
|
|
// Allows testing how DevTools behaves when it encounters console.error without cluttering the test output.
|
|
// Errors can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
|
|
return;
|
|
}
|
|
originalConsoleError.apply(console, args);
|
|
};
|
|
const originalConsoleWarn = console.warn;
|
|
console.warn = (...args) => {
|
|
if (shouldIgnoreConsoleErrorOrWarn(args)) {
|
|
// Allows testing how DevTools behaves when it encounters console.warn without cluttering the test output.
|
|
// Warnings can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
|
|
return;
|
|
}
|
|
originalConsoleWarn.apply(console, args);
|
|
};
|
|
|
|
// Initialize filters to a known good state.
|
|
setSavedComponentFilters(getDefaultComponentFilters());
|
|
global.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getDefaultComponentFilters();
|
|
|
|
// Also initialize inline warnings so that we can test them.
|
|
global.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = true;
|
|
|
|
installHook(global);
|
|
|
|
const bridgeListeners = [];
|
|
const bridge = new Bridge({
|
|
listen(callback) {
|
|
bridgeListeners.push(callback);
|
|
return () => {
|
|
const index = bridgeListeners.indexOf(callback);
|
|
if (index >= 0) {
|
|
bridgeListeners.splice(index, 1);
|
|
}
|
|
};
|
|
},
|
|
send(event: string, payload: any, transferable?: Array<any>) {
|
|
bridgeListeners.forEach(callback => callback({event, payload}));
|
|
},
|
|
});
|
|
|
|
const agent = new Agent(((bridge: any): BackendBridge));
|
|
|
|
const hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
|
|
initBackend(hook, agent, global);
|
|
|
|
const store = new Store(((bridge: any): FrontendBridge));
|
|
|
|
global.agent = agent;
|
|
global.bridge = bridge;
|
|
global.store = store;
|
|
|
|
const readFileSync = require('fs').readFileSync;
|
|
async function mockFetch(url) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => readFileSync(__dirname + url, 'utf-8'),
|
|
};
|
|
}
|
|
global.fetch = mockFetch;
|
|
});
|
|
afterEach(() => {
|
|
delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
|
|
// It's important to reset modules between test runs;
|
|
// Without this, ReactDOM won't re-inject itself into the new hook.
|
|
// It's also important to reset after tests, rather than before,
|
|
// so that we don't disconnect the ReactCurrentDispatcher ref.
|
|
jest.resetModules();
|
|
});
|