From 76182aa546e0637ebc296db0ae2b1d0a597ebdd2 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:28:55 +0200 Subject: [PATCH 01/14] perf(loader): skip the capture wait when push runs the callback --- .../modularLoader/boot-order.test.mts | 19 +++++++++++++++++++ src/jsHelper/modularLoader/webpackCapture.ts | 2 ++ 2 files changed, 21 insertions(+) diff --git a/src/jsHelper/modularLoader/boot-order.test.mts b/src/jsHelper/modularLoader/boot-order.test.mts index 5418464424..905c984b31 100644 --- a/src/jsHelper/modularLoader/boot-order.test.mts +++ b/src/jsHelper/modularLoader/boot-order.test.mts @@ -42,6 +42,25 @@ describe("modular loader boot order", () => { assert.equal(typeof captured, "function"); }); + it("returns without waiting when push runs the runtime callback synchronously", async () => { + let waits = 0; + let captured: unknown; + const ok = await captureModule!.captureWebpackRequire({ + maxWaitMs: 30000, + now: Date.now, + wait: async () => { + waits++; + }, + getQueue: () => ({ + push: (chunk: unknown[]) => (chunk[2] as (require: unknown) => unknown)(() => "webpack"), + }), + getCaptured: () => captured, + setCaptured: (require) => (captured = require), + }); + assert.equal(ok, true); + assert.equal(waits, 0); + }); + it("times out when queue push never invokes the runtime callback", async () => { let now = 0; const ok = await captureModule!.captureWebpackRequire({ diff --git a/src/jsHelper/modularLoader/webpackCapture.ts b/src/jsHelper/modularLoader/webpackCapture.ts index 6823378c52..fa596471c4 100644 --- a/src/jsHelper/modularLoader/webpackCapture.ts +++ b/src/jsHelper/modularLoader/webpackCapture.ts @@ -23,6 +23,8 @@ export async function captureWebpackRequire(options: WebpackCaptureOptions): Pro return require; }, ]); + // The runtime usually runs the chunk callback inside push. + if (typeof options.getCaptured() === "function") return true; } // Capture is an ordered poll: each wait must observe whether the runtime // callback fired before another attempt or the deadline check. From c4ac903b4a58b000a608031ee82865c30b3db364 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:29:50 +0200 Subject: [PATCH 02/14] fix(wrapper): limit the scrolling fix to Spotify 1.2.56 and older --- src/jsHelper/spicetifyWrapper/platform.js | 4 ++-- src/jsHelper/spicetifyWrapper/shared/version.js | 13 +++++++++++++ .../spicetifyWrapper/shared/version.test.mts | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/jsHelper/spicetifyWrapper/shared/version.js create mode 100644 src/jsHelper/spicetifyWrapper/shared/version.test.mts diff --git a/src/jsHelper/spicetifyWrapper/platform.js b/src/jsHelper/spicetifyWrapper/platform.js index 6b5f74e14d..141851467c 100644 --- a/src/jsHelper/spicetifyWrapper/platform.js +++ b/src/jsHelper/spicetifyWrapper/platform.js @@ -14,6 +14,7 @@ import { } from "./shared/daemonRpc.js"; import { managedSpotify } from "./shared/managedSpotify.js"; import { installUpdateJobBridge, updateAndApply, updateApiSupported } from "./shared/updateAndApply.js"; +import { isVersionBefore } from "./shared/version.js"; const updateAndApplySupported = async () => { const daemonSupported = await daemonUpdateAndApplySupported(); @@ -100,8 +101,7 @@ function applyScrollingFix() { } // Run only for 1.2.56 and lower - const version = Spicetify.Platform.version.split(".").map((i) => Number.parseInt(i, 10)); - if (version[1] >= 2 && version[2] >= 57) return; + if (!isVersionBefore(Spicetify.Platform.version, [1, 2, 57])) return; const scrollableElements = Array.from(document.querySelectorAll("*:not([data-scroll-optimized])")).filter((el) => { if ( diff --git a/src/jsHelper/spicetifyWrapper/shared/version.js b/src/jsHelper/spicetifyWrapper/shared/version.js new file mode 100644 index 0000000000..006d4067a6 --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/version.js @@ -0,0 +1,13 @@ +/** + * Whether a dotted Spotify version such as "1.3.3.264" is older than `target`. + * @param {string} version + * @param {number[]} target + */ +export function isVersionBefore(version, target) { + const parts = version.split(".").map((part) => Number.parseInt(part, 10) || 0); + for (let i = 0; i < target.length; i++) { + const part = parts[i] ?? 0; + if (part !== target[i]) return part < target[i]; + } + return false; +} diff --git a/src/jsHelper/spicetifyWrapper/shared/version.test.mts b/src/jsHelper/spicetifyWrapper/shared/version.test.mts new file mode 100644 index 0000000000..994b401af5 --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/shared/version.test.mts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { isVersionBefore } from "./version.js"; + +describe("isVersionBefore", () => { + it("compares each component in order instead of independently", () => { + assert.equal(isVersionBefore("1.2.56.502", [1, 2, 57]), true); + assert.equal(isVersionBefore("1.2.57.463", [1, 2, 57]), false); + assert.equal(isVersionBefore("1.2.94.522", [1, 2, 57]), false); + assert.equal(isVersionBefore("1.3.3.264", [1, 2, 57]), false); + assert.equal(isVersionBefore("2.0.0", [1, 2, 57]), false); + assert.equal(isVersionBefore("1.1.99", [1, 2, 57]), true); + }); +}); From 0895d574f85d70800214ce95f03311588fd5d742 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:31:59 +0200 Subject: [PATCH 03/14] perf(loader): load the boot theme before other modules --- src/jsHelper/modularLoader/registry.test.mts | 23 +++++++++++++++++ src/jsHelper/modularLoader/registry.ts | 27 ++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/jsHelper/modularLoader/registry.test.mts b/src/jsHelper/modularLoader/registry.test.mts index 1d330c06b5..45285d435d 100644 --- a/src/jsHelper/modularLoader/registry.test.mts +++ b/src/jsHelper/modularLoader/registry.test.mts @@ -739,3 +739,26 @@ describe("persisted disable", () => { assert.equal(await r.disable("ext"), true, "disable still unloads without persistence"); }); }); + +describe("boot load order and prefetch", () => { + const theme = (id: string, deps: Record = {}) => + mod(id, "1.0.0", { kind: "theme", entries: { js: "index.js", css: "index.css" }, dependencies: deps }); + + it("loads the boot theme and its dependencies before other extensions", async () => { + const calls: string[] = []; + const r = new Registry( + manifest([ + mod("stdlib", "1.0.0"), + mod("alpha", "1.0.0", { dependencies: { stdlib: "^1.0.0" } }), + mod("chrome", "1.0.0"), + theme("zeta", { chrome: "^1.0.0", stdlib: "^1.0.0" }), + ]), + trackingEffects(calls), + ); + const report = await r.boot(); + assert.deepEqual(report.failed, {}); + const imports = calls.filter((c) => c.startsWith("import:")).map((c) => c.split("/")[2]); + assert.deepEqual(imports, ["stdlib", "chrome", "zeta", "alpha"]); + }); + +}); diff --git a/src/jsHelper/modularLoader/registry.ts b/src/jsHelper/modularLoader/registry.ts index 76e5bc4680..5d11ae0fe7 100644 --- a/src/jsHelper/modularLoader/registry.ts +++ b/src/jsHelper/modularLoader/registry.ts @@ -245,10 +245,10 @@ export class Registry { } } - // runLoads executes preload/css/load for all eligible modules, after the - // client is up. Call runMixins first during early boot. - async runLoads(report: BootReport): Promise { - const disabled = this.disabledSet(); + // bootLoadOrder is the order runLoads walks: the boot theme and everything + // it depends on first, so the client is themed before extensions mount, + // then every other eligible module in dependency order. + private bootLoadOrder(report: BootReport, disabled: Set): { order: string[]; bootTheme?: string } { const eligible = this.eligibleOrder(report, disabled); // Two installed themes would otherwise both load at boot. The persisted // preference (last theme the user enabled) wins; without one, the last @@ -266,7 +266,24 @@ export class Registry { : preferred && themes.includes(preferred) ? preferred : themes[themes.length - 1]; - for (const id of eligible) { + const first = new Set(); + const pull = (id: string) => { + const m = this.modules.get(id); + if (!m || first.has(id)) return; + for (const dep of Object.keys(m.dependencies)) pull(dep); + first.add(id); + }; + if (bootTheme) pull(bootTheme); + const order = [...eligible.filter((id) => first.has(id)), ...eligible.filter((id) => !first.has(id))]; + return { order, bootTheme }; + } + + // runLoads executes preload/css/load for all eligible modules, after the + // client is up. Call runMixins first during early boot. + async runLoads(report: BootReport): Promise { + const disabled = this.disabledSet(); + const { order, bootTheme } = this.bootLoadOrder(report, disabled); + for (const id of order) { if (report.failed[id]) continue; if (this.isTheme(id) && id !== bootTheme) { this.effects.log("info", `skipping theme ${id}: ${bootTheme} is active (one theme at a time)`); From 590b725a0e891f4e374d5f549d063dae907c2694 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:32:07 +0200 Subject: [PATCH 04/14] perf(loader): prefetch module entries while the client boots --- src/jsHelper/modularLoader/index.ts | 25 ++++++++++++++++++-- src/jsHelper/modularLoader/registry.test.mts | 23 ++++++++++++++++++ src/jsHelper/modularLoader/registry.ts | 17 +++++++++++++ src/jsHelper/modularLoader/types.ts | 2 ++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/jsHelper/modularLoader/index.ts b/src/jsHelper/modularLoader/index.ts index d6896b53d2..4ff4a406e4 100644 --- a/src/jsHelper/modularLoader/index.ts +++ b/src/jsHelper/modularLoader/index.ts @@ -71,9 +71,28 @@ async function cssFromSource(text: string): Promise { return text; } +const prefetchedCss = new Map>(); + +function prefetch(path: string, kind: "js" | "css"): void { + if (kind === "js") { + const link = document.createElement("link"); + link.rel = "modulepreload"; + link.href = path; + document.head.appendChild(link); + return; + } + if (prefetchedCss.has(path)) return; + const text = fetch(path).then((res) => res.text()); + // A failed prefetch is retried by loadCss, so it must not surface here. + text.catch(() => {}); + prefetchedCss.set(path, text); +} + async function loadCss(path: string): Promise { - const res = await fetch(path); - return cssFromSource(await res.text()); + const prefetched = prefetchedCss.get(path); + prefetchedCss.delete(path); + const text = (await prefetched?.catch(() => undefined)) ?? (await (await fetch(path)).text()); + return cssFromSource(text); } // parseColorSchemes parses classic spicetify color.ini into named @@ -486,6 +505,7 @@ async function boot(): Promise { importSource, loadCss, cssFromSource, + prefetch, adoptCss, applyScheme, activeThemePref: { @@ -506,6 +526,7 @@ async function boot(): Promise { // the 2024 mixin machinery created it. Define it empty so references are // inert instead of fatal. (globalThis as never as Record).CHUNKS ??= {}; + registry.prefetchLoads(report); await registry.runMixins(report); await bootClient(transforms); diff --git a/src/jsHelper/modularLoader/registry.test.mts b/src/jsHelper/modularLoader/registry.test.mts index 45285d435d..1e21f02af9 100644 --- a/src/jsHelper/modularLoader/registry.test.mts +++ b/src/jsHelper/modularLoader/registry.test.mts @@ -761,4 +761,27 @@ describe("boot load order and prefetch", () => { assert.deepEqual(imports, ["stdlib", "chrome", "zeta", "alpha"]); }); + it("prefetches only the staged entries boot will import or adopt", () => { + const prefetched: string[] = []; + const effects = { ...trackingEffects([]), prefetch: (path: string, kind: string) => prefetched.push(`${kind}:${path}`) }; + const r = new Registry( + manifest([ + mod("stdlib", "1.0.0"), + mod("mixer", "1.0.0", { hasMixins: true, entries: { js: "index.js", css: "index.css" } }), + mod("off", "1.0.0"), + mod("local", "1.0.0"), + theme("theme-a"), + theme("theme-b"), + ]), + { ...effects, disabledPref: { get: () => ["off"], add: () => {}, remove: () => {} } }, + ); + r.registerLocal({ metadata: mod("local", "1.0.0"), files: { "index.js": "" } }); + r.prefetchLoads({ loaded: [], failed: {} }); + assert.deepEqual(prefetched, [ + "js:/modules/theme-b/index.js", + "css:/modules/theme-b/index.css", + "js:/modules/stdlib/index.js", + "css:/modules/mixer/index.css", + ]); + }); }); diff --git a/src/jsHelper/modularLoader/registry.ts b/src/jsHelper/modularLoader/registry.ts index 5d11ae0fe7..6e1b1e6605 100644 --- a/src/jsHelper/modularLoader/registry.ts +++ b/src/jsHelper/modularLoader/registry.ts @@ -278,6 +278,23 @@ export class Registry { return { order, bootTheme }; } + // prefetchLoads starts fetching the entries runLoads will import or adopt, + // without evaluating anything, so they download while the client boots. + // Local installs are already in memory, and mixin modules were imported + // by runMixins. + prefetchLoads(report: BootReport): void { + if (!this.effects.prefetch) return; + const disabled = this.disabledSet(); + const { order, bootTheme } = this.bootLoadOrder(report, disabled); + for (const id of order) { + const m = this.modules.get(id)!; + if (report.failed[id] || this.localFiles.has(id)) continue; + if (this.isTheme(id) && id !== bootTheme) continue; + if (m.entries.js && !m.hasMixins) this.effects.prefetch(entryUrl(id, m.entries.js), "js"); + if (m.entries.css) this.effects.prefetch(entryUrl(id, m.entries.css), "css"); + } + } + // runLoads executes preload/css/load for all eligible modules, after the // client is up. Call runMixins first during early boot. async runLoads(report: BootReport): Promise { diff --git a/src/jsHelper/modularLoader/types.ts b/src/jsHelper/modularLoader/types.ts index 819152a8d8..159c9fca6e 100644 --- a/src/jsHelper/modularLoader/types.ts +++ b/src/jsHelper/modularLoader/types.ts @@ -88,6 +88,8 @@ export interface Effects { // turned back on. Only disable()/enable() write here: internal unloads // (theme switching, dependency cascades) persist nothing. disabledPref?: { get(): string[]; add(identifier: string): void; remove(identifier: string): void }; + // Starts downloading a module entry ahead of runLoads without evaluating it. + prefetch?(path: string, kind: "js" | "css"): void; log(level: "info" | "error", ...args: unknown[]): void; } From e20cc379c6e6e77803c5cea28888ab2e69326e79 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:32:07 +0200 Subject: [PATCH 05/14] perf(loader): poll for the client every 25ms --- src/jsHelper/modularLoader/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsHelper/modularLoader/index.ts b/src/jsHelper/modularLoader/index.ts index 4ff4a406e4..4391604da0 100644 --- a/src/jsHelper/modularLoader/index.ts +++ b/src/jsHelper/modularLoader/index.ts @@ -402,7 +402,7 @@ async function waitForClient(timeoutMs: number): Promise { // Base gate: the main view is mounted and the Platform API is up. while (Date.now() < deadline) { if (document.querySelector("main") && spice()?.Platform) break; - await new Promise((r) => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 25)); } if (!(document.querySelector("main") && spice()?.Platform)) return false; // Platform lands before the webpack-extracted surface (URI, Mousetrap, From 5a3495b35b7571f24b663193f1b19d2f6aa17c3a Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 22:51:40 +0200 Subject: [PATCH 06/14] fix(loader): keep theme stylesheets after other module stylesheets --- src/jsHelper/modularLoader/adopt-css.test.mts | 39 +++++++++++++++++++ src/jsHelper/modularLoader/index.ts | 15 +++++-- src/jsHelper/modularLoader/registry.ts | 4 +- src/jsHelper/modularLoader/types.ts | 2 +- 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/jsHelper/modularLoader/adopt-css.test.mts diff --git a/src/jsHelper/modularLoader/adopt-css.test.mts b/src/jsHelper/modularLoader/adopt-css.test.mts new file mode 100644 index 0000000000..06c4f7ee51 --- /dev/null +++ b/src/jsHelper/modularLoader/adopt-css.test.mts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { Window } from "happy-dom"; + +import { adoptCss } from "./index.ts"; + +const win = new Window(); +Object.assign(globalThis, { document: win.document, CSSStyleSheet: win.CSSStyleSheet }); + +const sheet = (name: string) => { + const s = new win.CSSStyleSheet(); + s.replaceSync(`.${name} {}`); + return s; +}; +const order = () => document.adoptedStyleSheets.map((s) => s.cssRules[0].cssText.split(" ")[0]); + +describe("adoptCss", () => { + it("keeps theme sheets after extension sheets adopted later", () => { + document.adoptedStyleSheets = []; + const disposeTheme = adoptCss(sheet("theme"), { theme: true }); + adoptCss(sheet("alpha")); + adoptCss(sheet("beta")); + assert.deepEqual(order(), [".alpha", ".beta", ".theme"]); + disposeTheme(); + adoptCss(sheet("gamma")); + assert.deepEqual(order(), [".alpha", ".beta", ".gamma"]); + }); + + it("keeps theme