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
50 changes: 49 additions & 1 deletion patches/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
135 changes: 135 additions & 0 deletions patches/overlayscrollbars@2.15.1.patch
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 6 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
142 changes: 142 additions & 0 deletions src/lib/overlay-scrollbars-memory.test.ts
Original file line number Diff line number Diff line change
@@ -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("<!doctype html><body>", {
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)
}
})
}
)
Loading