From b22a18ae6f8cfdbad4c62be136b090ec4cbf9867 Mon Sep 17 00:00:00 2001 From: Kun Date: Thu, 8 Oct 2026 23:53:56 +0800 Subject: [PATCH 1/3] perf(layout): skip the body-level OverlayScrollbars on the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OverlayScrollbarsInit attaches an instance to `document.body`. The library measures its host from a window `resize` listener and a ResizeObserver, each forcing a synchronous layout of the host — here the whole document — so every window resize step paid two extra full-page layouts on top of the real one. On /workspace the shell is fixed and viewport-filling and every pane scrolls inside itself, so the body never scrolls and the instance has nothing to do. Profiled in Chrome against a long transcript with long-animation-frame entries: ~66ms resize frames, with both extra layouts attributed to OverlayScrollbars, down to ~33ms and no frame over 50ms without it. In the desktop app that lag also holds back the window itself, which waits on the webview's next frame. The instance is created per route and follows client-side navigation: `/` routes into the workspace with `router.replace` while this component stays mounted, so one created on the way in is destroyed on arrival. Other pages keep the body scrollbar. Co-Authored-By: Claude Sonnet 5.5 --- src/components/overlay-scrollbars-init.tsx | 31 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/overlay-scrollbars-init.tsx b/src/components/overlay-scrollbars-init.tsx index da65638348..5f6a173eb4 100644 --- a/src/components/overlay-scrollbars-init.tsx +++ b/src/components/overlay-scrollbars-init.tsx @@ -1,11 +1,30 @@ "use client" import { useEffect } from "react" +import { usePathname } from "next/navigation" import "overlayscrollbars/overlayscrollbars.css" import { useOverlayScrollbars } from "overlayscrollbars-react" +// Routes whose page is a fixed, viewport-filling shell: the body never +// scrolls there (every pane scrolls inside itself), so a body-level instance +// has nothing to do — and it is not free. OverlayScrollbars measures its host +// from a window `resize` listener and a ResizeObserver, each forcing a +// synchronous layout of the host — here the whole document — so every window +// resize step paid two full-page layouts on top of the real one. Profiled on +// the workspace with a long transcript: ~66ms resize frames (vs a 16ms +// budget), back under the long-frame threshold without it; in the desktop app +// that lag also held back the window itself, which waits on the webview. +const BODY_FIXED_ROUTES = ["/workspace"] + +function isBodyFixedRoute(pathname: string | null): boolean { + return BODY_FIXED_ROUTES.some( + (route) => pathname === route || pathname?.startsWith(`${route}/`) + ) +} + export function OverlayScrollbarsInit() { - const [init] = useOverlayScrollbars({ + const pathname = usePathname() + const [init, instance] = useOverlayScrollbars({ options: { scrollbars: { theme: "os-theme-codeg", @@ -17,9 +36,17 @@ export function OverlayScrollbarsInit() { defer: true, }) + // Follows client-side navigation too: `/` routes into the workspace with + // `router.replace`, which keeps this component mounted, so an instance made + // on the way in has to be torn down on arrival. + const bodyFixed = isBodyFixedRoute(pathname) useEffect(() => { + if (bodyFixed) { + instance()?.destroy() + return + } init(document.body) - }, [init]) + }, [bodyFixed, init, instance]) return null } From aff36048789cab9c129dffd7375a3d7ace9e8e5b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 13:54:56 +0800 Subject: [PATCH 2/3] fix(layout): cancel a pending body scrollbar init on entering the workspace The body-level OverlayScrollbars init is deferred to an idle callback and an animation frame. Destroying the instance on arrival at /workspace missed an init that had not run yet, since there was no instance to destroy, so it still landed on the workspace afterwards: for example when `/` redirects in a background tab, where frames do not run. The hook now lives in a child that only renders off fixed routes, so its own unmount cleanup cancels a pending init as well as destroying a live one. Its options are a module constant, so the re-render on each navigation no longer hands the instance a new options object. Tests drive the real hook and library, with idle and frame callbacks queued by hand. --- .../overlay-scrollbars-init.test.tsx | 107 ++++++++++++++++++ src/components/overlay-scrollbars-init.tsx | 45 ++++---- 2 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 src/components/overlay-scrollbars-init.test.tsx diff --git a/src/components/overlay-scrollbars-init.test.tsx b/src/components/overlay-scrollbars-init.test.tsx new file mode 100644 index 0000000000..4d771afa07 --- /dev/null +++ b/src/components/overlay-scrollbars-init.test.tsx @@ -0,0 +1,107 @@ +import { act, render } from "@testing-library/react" +import { OverlayScrollbars } from "overlayscrollbars" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const nav = vi.hoisted(() => ({ pathname: "/" })) + +vi.mock("next/navigation", () => ({ + usePathname: () => nav.pathname, +})) + +import { OverlayScrollbarsInit } from "./overlay-scrollbars-init" + +// Runs the real hook and the real library: the body instance is `defer`red +// into an idle callback that then waits for an animation frame. Both are +// queued here and only run when a test flushes them, so a test can keep an +// init pending across a route change, as a background tab does (frames do not +// run there). +const idle = new Map void>() +const frames = new Map() +let nextHandle = 0 + +function flushDeferred() { + act(() => { + for (const [handle, callback] of [...idle]) { + idle.delete(handle) + callback() + } + for (const [handle, callback] of [...frames]) { + frames.delete(handle) + callback(0) + } + }) +} + +// The static getter: the body's live instance, or undefined. Never creates. +const bodyInstance = () => OverlayScrollbars(document.body) + +function navigate(rerender: (ui: React.ReactElement) => void, path: string) { + nav.pathname = path + rerender() +} + +beforeEach(() => { + nav.pathname = "/" + vi.stubGlobal("requestIdleCallback", (callback: () => void) => { + idle.set(++nextHandle, callback) + return nextHandle + }) + vi.stubGlobal("cancelIdleCallback", (handle: number) => idle.delete(handle)) + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextHandle, callback) + return nextHandle + }) + vi.stubGlobal("cancelAnimationFrame", (handle: number) => + frames.delete(handle) + ) +}) + +afterEach(() => { + idle.clear() + frames.clear() + vi.unstubAllGlobals() +}) + +describe("OverlayScrollbarsInit", () => { + it("creates the body instance, deferred, on a page that scrolls", () => { + nav.pathname = "/settings/appearance" + render() + expect(bodyInstance()).toBeUndefined() + + flushDeferred() + + expect(bodyInstance()).toBeDefined() + }) + + it("never creates one on the workspace", () => { + nav.pathname = "/workspace" + render() + flushDeferred() + + expect(bodyInstance()).toBeUndefined() + expect(idle.size + frames.size).toBe(0) + }) + + it("destroys the instance when `/` hands over to the workspace", () => { + const { rerender } = render() + flushDeferred() + expect(bodyInstance()).toBeDefined() + + navigate(rerender, "/workspace") + + expect(bodyInstance()).toBeUndefined() + expect( + document.documentElement.hasAttribute("data-overlayscrollbars") + ).toBe(false) + }) + + it("cancels an init still pending when the workspace is reached", () => { + const { rerender } = render() + expect(idle.size + frames.size).toBeGreaterThan(0) + + navigate(rerender, "/workspace") + flushDeferred() + + expect(bodyInstance()).toBeUndefined() + }) +}) diff --git a/src/components/overlay-scrollbars-init.tsx b/src/components/overlay-scrollbars-init.tsx index 5f6a173eb4..514493c26a 100644 --- a/src/components/overlay-scrollbars-init.tsx +++ b/src/components/overlay-scrollbars-init.tsx @@ -3,7 +3,10 @@ import { useEffect } from "react" import { usePathname } from "next/navigation" import "overlayscrollbars/overlayscrollbars.css" -import { useOverlayScrollbars } from "overlayscrollbars-react" +import { + useOverlayScrollbars, + type UseOverlayScrollbarsParams, +} from "overlayscrollbars-react" // Routes whose page is a fixed, viewport-filling shell: the body never // scrolls there (every pane scrolls inside itself), so a body-level instance @@ -22,31 +25,35 @@ function isBodyFixedRoute(pathname: string | null): boolean { ) } +const BODY_SCROLLBARS_OPTIONS: UseOverlayScrollbarsParams["options"] = { + scrollbars: { + theme: "os-theme-codeg", + autoHide: "leave", + dragScroll: false, + }, + overflow: { x: "hidden" }, +} + export function OverlayScrollbarsInit() { const pathname = usePathname() - const [init, instance] = useOverlayScrollbars({ - options: { - scrollbars: { - theme: "os-theme-codeg", - autoHide: "leave", - dragScroll: false, - }, - overflow: { x: "hidden" }, - }, + // Follows client-side navigation too: `/` and `/login` route into the + // workspace with `router.replace`, which keeps this component mounted. + // Arriving there unmounts the instance's owner rather than destroying the + // instance, because the init is deferred: one that has not run yet has no + // instance to destroy, and only the hook's unmount cleanup cancels it. + if (isBodyFixedRoute(pathname)) return null + return +} + +function BodyOverlayScrollbars() { + const [init] = useOverlayScrollbars({ + options: BODY_SCROLLBARS_OPTIONS, defer: true, }) - // Follows client-side navigation too: `/` routes into the workspace with - // `router.replace`, which keeps this component mounted, so an instance made - // on the way in has to be torn down on arrival. - const bodyFixed = isBodyFixedRoute(pathname) useEffect(() => { - if (bodyFixed) { - instance()?.destroy() - return - } init(document.body) - }, [bodyFixed, init, instance]) + }, [init]) return null } From 20bece4ed113a12d0495389d84891f37d6dbda15 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 14:03:30 +0800 Subject: [PATCH 3/3] test(layout): cover the frame-stage init and instance reuse across routes Adds the hidden-tab case, where the deferred init has passed its idle stage and only waits for a frame when the workspace is reached, and pins that navigating between pages that scroll keeps the same body instance instead of remounting it. --- .../overlay-scrollbars-init.test.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/overlay-scrollbars-init.test.tsx b/src/components/overlay-scrollbars-init.test.tsx index 4d771afa07..c2f86723be 100644 --- a/src/components/overlay-scrollbars-init.test.tsx +++ b/src/components/overlay-scrollbars-init.test.tsx @@ -19,12 +19,17 @@ const idle = new Map void>() const frames = new Map() let nextHandle = 0 -function flushDeferred() { +function flushIdle() { act(() => { for (const [handle, callback] of [...idle]) { idle.delete(handle) callback() } + }) +} + +function flushFrames() { + act(() => { for (const [handle, callback] of [...frames]) { frames.delete(handle) callback(0) @@ -32,6 +37,11 @@ function flushDeferred() { }) } +function flushDeferred() { + flushIdle() + flushFrames() +} + // The static getter: the body's live instance, or undefined. Never creates. const bodyInstance = () => OverlayScrollbars(document.body) @@ -104,4 +114,27 @@ describe("OverlayScrollbarsInit", () => { expect(bodyInstance()).toBeUndefined() }) + + it("cancels an init already waiting for its frame, as in a hidden tab", () => { + const { rerender } = render() + flushIdle() + expect(frames.size).toBeGreaterThan(0) + + navigate(rerender, "/workspace") + flushFrames() + + expect(bodyInstance()).toBeUndefined() + }) + + it("keeps the same instance across pages that scroll", () => { + nav.pathname = "/settings/appearance" + const { rerender } = render() + flushDeferred() + const instance = bodyInstance() + expect(instance).toBeDefined() + + navigate(rerender, "/settings/general") + + expect(bodyInstance()).toBe(instance) + }) })