diff --git a/src/components/overlay-scrollbars-init.test.tsx b/src/components/overlay-scrollbars-init.test.tsx new file mode 100644 index 0000000000..c2f86723be --- /dev/null +++ b/src/components/overlay-scrollbars-init.test.tsx @@ -0,0 +1,140 @@ +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 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) + } + }) +} + +function flushDeferred() { + flushIdle() + flushFrames() +} + +// 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() + }) + + 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) + }) +}) diff --git a/src/components/overlay-scrollbars-init.tsx b/src/components/overlay-scrollbars-init.tsx index da65638348..514493c26a 100644 --- a/src/components/overlay-scrollbars-init.tsx +++ b/src/components/overlay-scrollbars-init.tsx @@ -1,19 +1,53 @@ "use client" 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 +// 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}/`) + ) +} + +const BODY_SCROLLBARS_OPTIONS: UseOverlayScrollbarsParams["options"] = { + scrollbars: { + theme: "os-theme-codeg", + autoHide: "leave", + dragScroll: false, + }, + overflow: { x: "hidden" }, +} export function OverlayScrollbarsInit() { + const pathname = usePathname() + // 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: { - scrollbars: { - theme: "os-theme-codeg", - autoHide: "leave", - dragScroll: false, - }, - overflow: { x: "hidden" }, - }, + options: BODY_SCROLLBARS_OPTIONS, defer: true, })