Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
65 changes: 64 additions & 1 deletion packages/web-shell/client/index-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';

function installMeasureGuard(
measure: (...args: unknown[]) => unknown,
clearMeasures?: () => void,
): Performance {
const html = readFileSync(resolve(__dirname, 'index.html'), 'utf8');
const script = Array.from(
Expand All @@ -14,7 +15,7 @@ function installMeasureGuard(

if (!script) throw new Error('Performance measure guard not found');

const performance = { measure };
const performance = { measure, clearMeasures };
Function('performance', 'DOMException', script)(performance, DOMException);
return performance as Performance;
}
Expand Down Expand Up @@ -59,4 +60,66 @@ describe('React performance measure guard', () => {

expect(measure).toHaveBeenCalledWith('custom-measure', options);
});

it('strips detail from any React devtools track, not just Components', () => {
const measure = vi.fn(() => 'measure');
const performance = installMeasureGuard(measure);
const options = {
start: 1,
end: 2,
detail: { devtools: { track: 'Blocking', properties: [['k', 'v']] } },
};

performance.measure('React', options);

expect(
(measure.mock.calls[0]?.[1] as PerformanceMeasureOptions).detail,
).toBeNull();
});

it('clears the measure timeline on a budget so entries cannot accumulate', () => {
const measure = vi.fn(() => 'measure');
const clearThis: unknown[] = [];
const clearMeasures = vi.fn(function (this: unknown) {
clearThis.push(this);
});
const fakePerformance = installMeasureGuard(measure, clearMeasures);
const reactOptions = {
start: 1,
end: 2,
// A non-Components track: the flood is dominated by lane/scheduler
// tracks, so the budget must count every React devtools measure.
detail: { devtools: { track: 'Blocking' } },
};

for (let i = 0; i < 16384; i += 1) {
fakePerformance.measure('⏱ track', reactOptions);
}
// The timeline is cleared with no name filter (React never names its
// measures) and with the performance object as receiver (a detached
// brand-checked clearMeasures throws Illegal invocation).
expect(clearMeasures).toHaveBeenCalledTimes(1);
expect(clearMeasures).toHaveBeenCalledWith();
expect(clearThis).toEqual([fakePerformance]);
// Every React measure is still forwarded, detail stripped — including
// the one that triggers the clear.
expect(measure).toHaveBeenCalledTimes(16384);
expect(
(measure.mock.calls[16383]?.[1] as PerformanceMeasureOptions).detail,
).toBeNull();

// The clear is not latched: a second full window clears again.
for (let i = 0; i < 16384; i += 1) {
fakePerformance.measure('⏱ track', reactOptions);
}
expect(clearMeasures).toHaveBeenCalledTimes(2);

// Non-React measures do not count toward the budget.
for (let i = 0; i < 16384 - 1; i += 1) {
fakePerformance.measure('custom-measure', {
detail: { source: 'web-shell' },
});
}
expect(clearMeasures).toHaveBeenCalledTimes(2);
});
});
19 changes: 17 additions & 2 deletions packages/web-shell/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,35 @@
Prevent React 19 dev-mode OOM crashes. React records component prop
details through performance.measure(), which structured-clones and retains
those details. Strip the detail before large transcripts reach the native
call while preserving the component timing and non-React measures.
call while preserving the component timing and non-React measures. The
stripped entries themselves still accumulate in the browser's unbounded
user-timing buffer — an idle dev page emits ~16k measures/s — so the
timeline is also cleared on a budget, or long-lived dev tabs exhaust the
renderer's PartitionAlloc address space and die with SIGABRT.
-->
<script>
!(function () {
if (typeof performance === 'undefined' || !performance.measure) return;
var m = performance.measure.bind(performance);
var clear =
typeof performance.clearMeasures === 'function'
? performance.clearMeasures.bind(performance)
: null;
var reactMeasures = 0;
var CLEAR_EVERY = 16384;
performance.measure = function () {
var options = arguments[1];
var devtools =
options &&
typeof options === 'object' &&
options.detail &&
options.detail.devtools;
if (devtools && devtools.track === 'Components ⚛') {
if (devtools) {
reactMeasures += 1;
if (clear && reactMeasures >= CLEAR_EVERY) {
reactMeasures = 0;
clear();
}
var args = Array.prototype.slice.call(arguments);
args[1] = Object.assign({}, options, { detail: null });
return m.apply(this, args);
Expand Down
Loading