Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
140 changes: 140 additions & 0 deletions src/components/overlay-scrollbars-init.test.tsx
Original file line number Diff line number Diff line change
@@ -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<number, () => void>()
const frames = new Map<number, FrameRequestCallback>()
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(<OverlayScrollbarsInit />)
}

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(<OverlayScrollbarsInit />)
expect(bodyInstance()).toBeUndefined()

flushDeferred()

expect(bodyInstance()).toBeDefined()
})

it("never creates one on the workspace", () => {
nav.pathname = "/workspace"
render(<OverlayScrollbarsInit />)
flushDeferred()

expect(bodyInstance()).toBeUndefined()
expect(idle.size + frames.size).toBe(0)
})

it("destroys the instance when `/` hands over to the workspace", () => {
const { rerender } = render(<OverlayScrollbarsInit />)
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(<OverlayScrollbarsInit />)
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(<OverlayScrollbarsInit />)
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(<OverlayScrollbarsInit />)
flushDeferred()
const instance = bodyInstance()
expect(instance).toBeDefined()

navigate(rerender, "/settings/general")

expect(bodyInstance()).toBe(instance)
})
})
52 changes: 43 additions & 9 deletions src/components/overlay-scrollbars-init.tsx
Original file line number Diff line number Diff line change
@@ -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 <BodyOverlayScrollbars />
}

function BodyOverlayScrollbars() {
const [init] = useOverlayScrollbars({
options: {
scrollbars: {
theme: "os-theme-codeg",
autoHide: "leave",
dragScroll: false,
},
overflow: { x: "hidden" },
},
options: BODY_SCROLLBARS_OPTIONS,
defer: true,
})

Expand Down
Loading