diff --git a/packages/web-shell/client/index-html.test.ts b/packages/web-shell/client/index-html.test.ts index 3db8e8bdce8..3d6d47f856b 100644 --- a/packages/web-shell/client/index-html.test.ts +++ b/packages/web-shell/client/index-html.test.ts @@ -2,9 +2,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -function installMeasureGuard( - measure: (...args: unknown[]) => unknown, -): Performance { +function extractMeasureScript(): string { const html = readFileSync(resolve(__dirname, 'index.html'), 'utf8'); const script = Array.from( html.matchAll(/]*)?>([\s\S]*?)<\/script>/g), @@ -13,8 +11,15 @@ function installMeasureGuard( .find((source) => source.includes('performance.measure =')); if (!script) throw new Error('Performance measure guard not found'); + return script; +} - const performance = { measure }; +function installMeasureGuard( + measure: (...args: unknown[]) => unknown, + clearMeasures?: () => void, +): Performance { + const script = extractMeasureScript(); + const performance = { measure, clearMeasures }; Function('performance', 'DOMException', script)(performance, DOMException); return performance as Performance; } @@ -59,4 +64,178 @@ 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 measureThis: unknown[] = []; + const measure = vi.fn(function (this: unknown): string { + measureThis.push(this); + return 'measure'; + }); + const clearThis: unknown[] = []; + const clearMeasures = vi.fn(function (this: unknown) { + clearThis.push(this); + }); + const fakePerformance = installMeasureGuard(measure, clearMeasures); + // The real flood mixes lane/scheduler tracks and measure names, so drive + // a mixed flood: the budget must count every React devtools measure + // regardless of track or name. + const tracks = ['Blocking', 'Transition', 'Suspense', 'Components ⚛']; + const names = ['⏱ lane', '⏱ render', '⏱ commit']; + const reactName = (index: number): string => names[index % names.length]; + let reactDriven = 0; + const driveReact = (count: number): void => { + for (let i = 0; i < count; i += 1) { + fakePerformance.measure(reactName(reactDriven), { + start: 1, + end: 2, + detail: { devtools: { track: tracks[reactDriven % tracks.length] } }, + }); + reactDriven += 1; + } + }; + + // The clear fires at exactly the budget, not one measure early. + driveReact(16383); + expect(clearMeasures).not.toHaveBeenCalled(); + driveReact(1); + expect(clearMeasures).toHaveBeenCalledTimes(1); + // 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).toHaveBeenCalledWith(); + expect(clearThis).toEqual([fakePerformance]); + // Every React measure is still forwarded with its name preserved and its + // detail stripped — including the one that triggers the clear. + expect(measure).toHaveBeenCalledTimes(16384); + expect(measure.mock.calls[16383]).toEqual([ + reactName(16383), + expect.objectContaining({ detail: null }), + ]); + + // The clear is not latched: a second full window clears again, and + // forwarding + stripping survive past the first clear. + driveReact(16384); + expect(clearMeasures).toHaveBeenCalledTimes(2); + expect(measure).toHaveBeenCalledTimes(32768); + expect(measure.mock.calls[32767]).toEqual([ + reactName(32767), + expect.objectContaining({ detail: null }), + ]); + + // A full window of non-React measures neither counts toward the budget + // nor is dropped or stripped after a clear. + const customOptions = { start: 1, end: 2, detail: { source: 'web-shell' } }; + for (let i = 0; i < 16384 - 1; i += 1) { + fakePerformance.measure('custom-measure', customOptions); + } + // The wrapper's return value passes through like the native call's. + const customResult = fakePerformance.measure( + 'custom-measure', + customOptions, + ); + expect(clearMeasures).toHaveBeenCalledTimes(2); + expect(customResult).toBe('measure'); + expect(measure).toHaveBeenCalledTimes(49152); + // Identity: the non-React options object is forwarded as-is. + expect(measure.mock.calls[49151]?.[0]).toBe('custom-measure'); + expect(measure.mock.calls[49151]?.[1]).toBe(customOptions); + // Value survival, not just reference: an in-place strip of the caller's + // options object must fail here. + expect( + (measure.mock.calls[49151]?.[1] as PerformanceMeasureOptions).detail, + ).toEqual({ source: 'web-shell' }); + + // Mixed app + React traffic still reaches the budget: interleaved + // non-React measures must not reset the counter. + for (let i = 0; i < 16384; i += 1) { + driveReact(1); + fakePerformance.measure('custom-measure', customOptions); + } + expect(clearMeasures).toHaveBeenCalledTimes(3); + + // A detached wrapper call still reaches the native measure bound to the + // performance object (both captures keep their .bind(performance)). + const detachedMeasure = fakePerformance.measure as ( + ...args: unknown[] + ) => unknown; + detachedMeasure('detached', { + start: 1, + end: 2, + detail: { devtools: { track: 'Blocking' } }, + }); + expect(measure).toHaveBeenCalledTimes(81921); + expect(measureThis.every((receiver) => receiver === fakePerformance)).toBe( + true, + ); + }); + + it('keeps measuring when clearMeasures is unavailable', () => { + const measure = vi.fn(() => 'measure'); + const performance = installMeasureGuard(measure); + const options = { + start: 1, + end: 2, + detail: { devtools: { track: 'Blocking' } }, + }; + + expect(() => { + for (let i = 0; i < 16384 + 1; i += 1) { + performance.measure('⏱ lane', options); + } + }).not.toThrow(); + expect(measure).toHaveBeenCalledTimes(16384 + 1); + }); + + it('forwards standard measure shapes untouched', () => { + const measure = vi.fn(() => 'measure'); + const performance = installMeasureGuard(measure); + const bareMeasure = performance.measure as ( + name: string, + options?: unknown, + ) => unknown; + const options = { start: 1, end: 2 }; + + expect(() => { + bareMeasure('plain'); + bareMeasure('null-options', null); + bareMeasure('string-mark', 'start-mark'); + bareMeasure('detail-less', options); + }).not.toThrow(); + + expect(bareMeasure('return-check', options)).toBe('measure'); + expect(measure.mock.calls[0]).toEqual(['plain']); + expect(measure.mock.calls[1]).toEqual(['null-options', null]); + expect(measure.mock.calls[2]).toEqual(['string-mark', 'start-mark']); + expect(measure.mock.calls[3]).toEqual(['detail-less', options]); + }); + + it('does not touch environments without performance.measure', () => { + const script = extractMeasureScript(); + const install = (performance: unknown): void => { + Function( + 'performance', + 'DOMException', + script, + )(performance, DOMException); + }; + + expect(() => install(undefined)).not.toThrow(); + expect(() => install({})).not.toThrow(); + }); }); diff --git a/packages/web-shell/client/index.html b/packages/web-shell/client/index.html index c53ef7011e0..645e78476db 100644 --- a/packages/web-shell/client/index.html +++ b/packages/web-shell/client/index.html @@ -51,12 +51,22 @@ 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. -->