diff --git a/packages/app/e2e/browser/explorer-resize-performance.spec.ts b/packages/app/e2e/browser/explorer-resize-performance.spec.ts new file mode 100644 index 0000000000..33cf48c94c --- /dev/null +++ b/packages/app/e2e/browser/explorer-resize-performance.spec.ts @@ -0,0 +1,118 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; + +interface DragMeasurement { + observer: MutationObserver; + stylesheetMutations: number; +} + +declare global { + interface Window { + __PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement; + } +} + +async function waitForResizePaint(page: Page): Promise { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); +} + +async function beginResizeMeasurement(page: Page): Promise { + await page.evaluate(() => { + const stylesheet = document.getElementById("unistyles-web"); + if (!stylesheet) throw new Error("Shared stylesheet is missing"); + globalThis.__PASEO_RESET_RENDER_PROFILE__!(); + const measurement: DragMeasurement = { + stylesheetMutations: 0, + observer: new MutationObserver((records) => { + measurement.stylesheetMutations += records.length; + }), + }; + measurement.observer.observe(stylesheet, { + childList: true, + subtree: true, + characterData: true, + }); + window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement; + }); +} + +async function previewExplorerResize(page: Page, distance: number) { + const handle = page + .getByTestId("workspace-explorer-sidebar-resize-handle") + .getByRole("separator"); + const explorer = page.getByTestId("workspace-explorer-sidebar"); + const bounds = await handle.boundingBox(); + if (!bounds) throw new Error("Explorer resize handle is missing"); + const before = (await explorer.boundingBox())!.width; + const x = bounds.x + bounds.width / 2; + const y = bounds.y + 100; + await page.mouse.move(x, y); + await page.mouse.down(); + await waitForResizePaint(page); + await beginResizeMeasurement(page); + for (let move = 1; move <= 20; move++) { + await page.mouse.move(x + (distance * move) / 20, y); + await waitForResizePaint(page); + } + const during = (await explorer.boundingBox())!.width; + const measurement = await page.evaluate(() => { + const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!; + state.observer.disconnect(); + return { + stylesheetMutations: state.stylesheetMutations, + paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}) + .filter(([id]) => id.startsWith("SplitPaneView:")) + .reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0), + }; + }); + await page.mouse.up(); + await waitForResizePaint(page); + const after = (await explorer.boundingBox())!.width; + expect(during).toBeCloseTo(before - distance, 0); + expect(after).toBeCloseTo(during, 0); + return { ...measurement, width: after }; +} + +async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise { + await page.reload(); + const explorer = page.getByTestId("workspace-explorer-sidebar"); + await expect(explorer).toBeVisible(); + await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0); +} + +test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({ + page, + withWorkspace, +}) => { + await page.setViewportSize({ width: 1600, height: 1000 }); + await page.addInitScript(() => { + globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true; + }); + const workspace = await withWorkspace(); + await workspace.navigateTo(); + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + await page.getByTestId("workspace-explorer-toggle").first().click(); + await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible(); + // Verify that direct pane renders are being counted before resetting them. + expect( + await page.evaluate(() => + Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) => + id.startsWith("SplitPaneView:"), + ), + ), + ).toBe(true); + const grown = await previewExplorerResize(page, -100); + const shrunk = await previewExplorerResize(page, 50); + expect(grown.stylesheetMutations).toBe(0); + expect(grown.paneRenders).toBe(0); + expect(shrunk.stylesheetMutations).toBe(0); + expect(shrunk.paneRenders).toBe(0); + await expectExplorerWidthSurvivesReload(page, shrunk.width); +}); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 9705f3be15..c6d95f40b7 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,5 +1,6 @@ import { Fragment, + memo, useCallback, useEffect, useMemo, @@ -88,7 +89,8 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; -import { RenderProfile } from "@/utils/render-profiler"; +import { recordRenderProfileReasons, RenderProfile } from "@/utils/render-profiler"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { isNative } from "@/constants/platform"; import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; @@ -435,7 +437,7 @@ export function SplitContainer({ : inheritedWindowChromeCorners; const mainColumnStyle = styles.mainColumn; const explorerSidebarDockStyle = useMemo( - () => [styles.explorerSidebarDock, { width: explorerSidebarWidth }], + () => [styles.explorerSidebarDock, inlineUnistylesStyle({ width: explorerSidebarWidth })], [explorerSidebarWidth], ); const handleWorkspaceShellLayout = useCallback((event: LayoutChangeEvent) => { @@ -1121,7 +1123,7 @@ function SplitNodeView({ ); } -function SplitPaneView({ +const SplitPaneView = memo(function SplitPaneView({ pane, uiTabs, isFocused, @@ -1158,6 +1160,7 @@ function SplitPaneView({ focusModeEnabled, onExitFocusMode, }: SplitPaneViewProps) { + recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]); const paneRef = useRef(null); const stableOnFocusPane = useStableEvent(onFocusPane); const paneState = useMemo( @@ -1312,7 +1315,7 @@ function SplitPaneView({ ); -} +}); function collectPanesById(node: SplitNode): Map { const next = new Map();