From d806110ebe6731629f8f63f438513496e41bb7ca Mon Sep 17 00:00:00 2001 From: kogekiplay <46434871+kogekiplay@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:19:55 +0800 Subject: [PATCH 1/2] fix(memory): avoid WebKit scrollbar timeline allocation growth --- patches/README.md | 43 +++++++ patches/overlayscrollbars@2.15.1.patch | 135 ++++++++++++++++++++++ pnpm-lock.yaml | 13 ++- pnpm-workspace.yaml | 3 + src/lib/overlay-scrollbars-memory.test.ts | 128 ++++++++++++++++++++ 5 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 patches/README.md create mode 100644 patches/overlayscrollbars@2.15.1.patch create mode 100644 src/lib/overlay-scrollbars-memory.test.ts diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000000..64fbdadba49 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,43 @@ +# OverlayScrollbars 2.15.1 + +The patch selects OverlayScrollbars' existing scroll-event implementation and +percentage-based handle positioning in WebKit. Its native implementation +animates scrollbar handles with +`ScrollTimeline` and `translate*(calc(-100% + 100cq*))`. On macOS 27.2 +(26B123), repeatedly updating those timelines retains native WebKit allocations +even when the DOM size and JavaScript live heap stay small. + +A system WKWebView reproducer with 24 scrolling elements grew from 293 MiB to +2,324 MiB in 40 seconds. The same timeline with a fixed-pixel transform stayed +near 111 MiB for 90 seconds; the scroll-event control stayed near 116 MiB for +150 seconds. The CSS fallback also uses container-relative transforms by default, +so WebKit scrollbar elements receive a class that keeps the original percentage +rules instead. The patch avoids the problematic interpolation without changing +the page's scrolling or disabling CSS animations. Blink keeps the native path. + +All four ESM/CommonJS package entrypoints used by package exports and the +legacy `main`/`module` fields carry the same change. The lockfile records the +patch hash so clean and frozen installs apply it. + +`src/lib/overlay-scrollbars-memory.test.ts` runs fresh ESM and CommonJS imports +for desktop/iOS WebKit, Chromium browsers, and browsers without the API. It +checks that WebKit creates handles without native timeline animations and that +Chromium retains the native implementation. Browser functionality and memory +also need a system WebKit check: initialize real scrollbars, repeatedly change +the viewport's scroll position, and verify that the handle moves while +`document.getAnimations()` contains no scrollbar `ScrollTimeline` animations. + +Revisit the patch when upgrading OverlayScrollbars or when WebKit fixes this +interpolation path. Test native physical footprint, rather than only a +JavaScript heap snapshot. + +Revalidation against the independently installed split on official main +`5c03d7af` used 24 scrolling elements in system WKWebView. The original path +reached 1,770.6 MiB within the first 20 seconds and was stopped at the experiment's +footprint cap. The patched path ran for 120 seconds (9,170 animation frames), +with a 208.9–276.7 MiB footprint range and a final 215.4 MiB sample. It had zero +scrollbar ScrollTimeline animations and continued moving the handles. A separate +16-case native geometry check passed with a maximum 0.235 CSS-pixel error; +Playwright WebKit passed all eight horizontal/vertical LTR/RTL drag and track +click cases. These are focused reproducer results, not a claim about all of +Codeg's long-running memory usage. diff --git a/patches/overlayscrollbars@2.15.1.patch b/patches/overlayscrollbars@2.15.1.patch new file mode 100644 index 00000000000..7a7daa17a8f --- /dev/null +++ b/patches/overlayscrollbars@2.15.1.patch @@ -0,0 +1,135 @@ +diff --git a/overlayscrollbars.cjs b/overlayscrollbars.cjs +index 7268fd59a854208ab38233e920a995bf864ef6f1..19e256594fef0a5a535e81aa2da34b7e2d4c7804 100644 +--- a/overlayscrollbars.cjs ++++ b/overlayscrollbars.cjs +@@ -62,7 +62,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1967,7 +1973,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.cjs.js b/overlayscrollbars.cjs.js +index 010b70bd487226ccd01fb6c0d772ab6bb8638c51..1bd9797196a3079563be955c637fa23b530311d7 100644 +--- a/overlayscrollbars.cjs.js ++++ b/overlayscrollbars.cjs.js +@@ -62,7 +62,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1967,7 +1973,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.esm.js b/overlayscrollbars.esm.js +index c66166eacc57c3ed6a95f01e1bcc290a58c3d176..e054c67b241249d2bf2059bdc9872b23832e2bf6 100644 +--- a/overlayscrollbars.esm.js ++++ b/overlayscrollbars.esm.js +@@ -56,7 +56,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1961,7 +1967,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.mjs b/overlayscrollbars.mjs +index 82dc05b44e92c5125aee59691a00c3babee7d934..dd4e6826e75f48fca6d8e2784ed351873f5ce749 100644 +--- a/overlayscrollbars.mjs ++++ b/overlayscrollbars.mjs +@@ -56,7 +56,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1961,7 +1967,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/styles/overlayscrollbars.css b/styles/overlayscrollbars.css +index 9fd60dce428b62713c7b2f216085402cf4ba8f3e..594d1304fc96a2e6b1c397971de457930ebd0c4a 100644 +--- a/styles/overlayscrollbars.css ++++ b/styles/overlayscrollbars.css +@@ -478,15 +478,15 @@ body > .os-scrollbar { + .os-scrollbar-track { + container-type: size; + } +- .os-scrollbar-horizontal .os-scrollbar-handle { ++ .os-scrollbar-horizontal:not(.os-scrollbar-webkit) .os-scrollbar-handle { + left: auto; + transform: translateX(calc(var(--os-scroll-percent-directional) * 100cqw + var(--os-scroll-percent-directional) * -100%)); + } +- .os-scrollbar-vertical .os-scrollbar-handle { ++ .os-scrollbar-vertical:not(.os-scrollbar-webkit) .os-scrollbar-handle { + top: auto; + transform: translateY(calc(var(--os-scroll-percent-directional) * 100cqh + var(--os-scroll-percent-directional) * -100%)); + } +- .os-scrollbar-rtl.os-scrollbar-horizontal .os-scrollbar-handle { ++ .os-scrollbar-rtl.os-scrollbar-horizontal:not(.os-scrollbar-webkit) .os-scrollbar-handle { + right: auto; + left: 0; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bced3b46bb3..087e276d4bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: katex: ^0.19.0 ansi-to-react>linkify-it: ^5.0.2 +patchedDependencies: + overlayscrollbars@2.15.1: d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328 + importers: .: @@ -167,10 +170,10 @@ importers: version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) overlayscrollbars: specifier: ^2.15.1 - version: 2.15.1 + version: 2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328) overlayscrollbars-react: specifier: ^0.5.6 - version: 0.5.6(overlayscrollbars@2.15.1)(react@19.2.4) + version: 0.5.6(overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328))(react@19.2.4) pdfjs-dist: specifier: 6.4.299 version: 6.4.299 @@ -13025,12 +13028,12 @@ snapshots: outvariant@1.4.3: {} - overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4): + overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328))(react@19.2.4): dependencies: - overlayscrollbars: 2.15.1 + overlayscrollbars: 2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328) react: 19.2.4 - overlayscrollbars@2.15.1: {} + overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328): {} own-keys@1.0.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5f7f886596..16c1a68ec32 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,3 +33,6 @@ minimumReleaseAgeExclude: - '@tauri-apps/plugin-dialog@2.8.3' - '@tauri-apps/plugin-opener@2.7.2' - '@tauri-apps/plugin-updater@2.14.0' + +patchedDependencies: + overlayscrollbars@2.15.1: patches/overlayscrollbars@2.15.1.patch diff --git a/src/lib/overlay-scrollbars-memory.test.ts b/src/lib/overlay-scrollbars-memory.test.ts new file mode 100644 index 00000000000..55d5e62c446 --- /dev/null +++ b/src/lib/overlay-scrollbars-memory.test.ts @@ -0,0 +1,128 @@ +import { execFileSync } from "node:child_process" +import { describe, expect, it } from "vitest" + +const WEBKIT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko)" +const IPHONE = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko)" + +// The dependency captures browser APIs at module evaluation. A fresh process +// per browser also exercises both package entrypoints without Node's external +// module cache making every case reuse the first browser's feature detection. +function probe(userAgent: string, supported = true) { + return ["esm", "cjs"].map( + (entrypoint) => + JSON.parse( + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { createRequire } from "node:module" + import { JSDOM } from "jsdom" + const [userAgent, entrypoint, supported] = process.argv.slice(1) + const dom = new JSDOM("
", { + pretendToBeVisual: true, + }) + const { window } = dom + Object.assign(globalThis, { + window, document: window.document, + HTMLElement: window.HTMLElement, Element: window.Element, + }) + Object.defineProperty(window.navigator, "userAgent", { + value: userAgent, + }) + let constructors = 0 + let animations = 0 + if (supported === "true") { + window.ScrollTimeline = class { + constructor() { constructors++ } + } + } + window.Element.prototype.animate = () => { + animations++ + return { cancel() {} } + } + const { OverlayScrollbars } = entrypoint === "cjs" + ? createRequire(import.meta.url)("overlayscrollbars") + : await import("overlayscrollbars") + const host = window.document.createElement("div") + window.document.body.append(host) + const instance = OverlayScrollbars(host, {}) + const result = { + native: OverlayScrollbars.env().scrollTimeline, + constructors, animations, + handle: instance.elements().scrollbarVertical.handle instanceof window.HTMLElement, + webkitClass: instance.elements().scrollbarVertical.scrollbar.classList.contains("os-scrollbar-webkit"), + } + instance.destroy() + dom.window.close() + console.log(JSON.stringify(result)) + `, + userAgent, + entrypoint, + String(supported), + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ) + ) as { + native: boolean + constructors: number + animations: number + handle: boolean + webkitClass: boolean + } + ) +} + +describe("OverlayScrollbars native memory workaround", () => { + it.each([ + ["WKWebView", WEBKIT], + ["Safari", `${WEBKIT} Version/18.0 Safari/605.1.15`], + ["iOS Chrome (WebKit)", `${IPHONE} CriOS/130.0.0.0 Mobile/15E148`], + ["iOS Firefox (WebKit)", `${IPHONE} FxiOS/130.0 Mobile/15E148`], + [ + "WebKitGTK", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/60.5 Safari/605.1.15", + ], + ])("uses the scroll-event fallback in %s", (_name, userAgent) => { + for (const result of probe(userAgent)) { + expect(result).toEqual({ + native: false, + constructors: 0, + animations: 0, + handle: true, + webkitClass: true, + }) + } + }) + + it.each([ + ["Chrome", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36`], + ["Edge", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36 Edg/130.0`], + ["Opera", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36 OPR/115.0`], + ["Chromium", `${WEBKIT} Chromium/130.0.0.0 Safari/537.36`], + ])("keeps native scroll timelines in %s", (_name, userAgent) => { + for (const result of probe(userAgent)) { + expect(result.native).toBe(true) + expect(result.constructors).toBeGreaterThan(0) + expect(result.animations).toBeGreaterThan(0) + expect(result.handle).toBe(true) + expect(result.webkitClass).toBe(false) + } + }) + + it("works when the browser has no ScrollTimeline API", () => { + for (const result of probe(WEBKIT, false)) { + expect(result.native).toBe(false) + expect(result.constructors).toBe(0) + expect(result.animations).toBe(0) + expect(result.handle).toBe(true) + expect(result.webkitClass).toBe(true) + } + }) +}) From 4eedf0d5af2bf0fb5d48f14dcba8fbf2a48ef225 Mon Sep 17 00:00:00 2001 From: xintaofei