diff --git a/patches/README.md b/patches/README.md index 5c365be446..7c82c409dd 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,4 +1,8 @@ -# Tauri API 2.12.4 event callback cleanup +# Dependency patches + +Each section explains one `patchedDependencies` entry in `pnpm-workspace.yaml`. + +## Tauri API 2.12.4 event callback cleanup Tauri creates a JavaScript callback before awaiting native listener registration. Its native registration eval and invoke response can reach WKWebView in either @@ -23,3 +27,47 @@ checking as well, including tests rather than just Next's production build. Revisit this patch on upgrades to `@tauri-apps/api` or after upstream provides an equivalent callback lifetime fix. It does not change the Rust Tauri event plugin. + +## 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 0000000000..7a7daa17a8 --- /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 a4975ad8b8..fb4ef5ab26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: patchedDependencies: '@tauri-apps/api@2.12.4': cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce + overlayscrollbars@2.15.1: d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328 importers: @@ -170,10 +171,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 @@ -13028,12 +13029,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 9697f379db..b094c0ad62 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,3 +36,4 @@ minimumReleaseAgeExclude: patchedDependencies: '@tauri-apps/api@2.12.4': patches/@tauri-apps__api@2.12.4.patch + 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 0000000000..bf903ec972 --- /dev/null +++ b/src/lib/overlay-scrollbars-memory.test.ts @@ -0,0 +1,142 @@ +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)" + +// Each case spawns two Node processes that load jsdom; under a full parallel +// test run that can exceed Vitest's default 5 s per-test budget. +const CHILD_TIMEOUT_MS = 20_000 + +// 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"], + // A synchronous child cannot be interrupted by Vitest's timer, so + // bound it here to fail with a clear error instead of hanging. + timeout: CHILD_TIMEOUT_MS, + } + ) + ) as { + native: boolean + constructors: number + animations: number + handle: boolean + webkitClass: boolean + } + ) +} + +describe( + "OverlayScrollbars native memory workaround", + { timeout: CHILD_TIMEOUT_MS * 2 + 5_000 }, + () => { + 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) + } + }) + } +)