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
88 changes: 87 additions & 1 deletion rust/crates/spicetify/src/module/stage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ pub(crate) struct StagedModule {
pub identifier: String,
#[serde(flatten)]
pub metadata: ModuleMetadata,
/// Every other script of a multi-file module, relative to its directory,
/// so the loader can fetch the whole tree while the client boots instead
/// of one import level at a time.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub preload: Vec<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -280,6 +285,49 @@ fn remap_source(
Err(anyhow::anyhow!("classmap references failed ({})", parts.join("; ")))
}

// module_scripts lists a staged module's JavaScript files other than its
// entry, sorted, when it has a JS entry and more than one script. Tests,
// specs and tool configs are left out; hidden directories are kept because
// pnpm installs dependencies under node_modules/.pnpm.
fn module_scripts(dir: &Path, entry: Option<&str>) -> Vec<String> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.filter_map(std::result::Result::ok) {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
if path.is_dir() {
if name != "__tests__" {
walk(root, &path, out);
}
continue;
}
let script = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("js") || e.eq_ignore_ascii_case("mjs"));
if !script || [".test.", ".spec.", ".config."].iter().any(|part| name.contains(part)) {
continue;
}
if let Ok(relative) = path.strip_prefix(root) {
let parts: Vec<_> =
relative.components().map(|c| c.as_os_str().to_string_lossy()).collect();
out.push(parts.join("/"));
}
}
}
let Some(entry) = entry.map(|e| e.trim_start_matches("./")) else {
return Vec::new();
};
let mut scripts = Vec::new();
walk(dir, dir, &mut scripts);
if scripts.len() < 2 {
return Vec::new();
}
scripts.retain(|script| script != entry);
scripts.sort();
scripts
}

fn stage_tree(
src_root: &Path,
out_dir: &Path,
Expand Down Expand Up @@ -407,7 +455,8 @@ pub(crate) fn stage_modules(
tracing::warn!("skipping module {identifier}: {e}");
continue;
}
staged.push(StagedModule { identifier, metadata });
let preload = module_scripts(&out_dir, metadata.entries.get("js").and_then(|v| v.as_str()));
staged.push(StagedModule { identifier, metadata, preload });
}

if staged.is_empty() {
Expand Down Expand Up @@ -518,6 +567,43 @@ mod tests {
std::fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn lists_the_other_scripts_of_a_multi_file_module() {
let root = scratch("module-scripts");
let tree = root.join("tree");
for dir in ["src/webpack", "node_modules/.pnpm/dep", "__tests__"] {
std::fs::create_dir_all(tree.join(dir)).expect("tree dirs");
}
for file in [
"index.js",
"load.js",
"src/webpack/index.js",
"src/util.test.mjs",
"src/util.spec.js",
"vite.config.js",
"node_modules/.pnpm/dep/index.js",
"__tests__/a.js",
"index.css",
"index.js.map",
] {
std::fs::write(tree.join(file), "").expect("tree file");
}
assert_eq!(
module_scripts(&tree, Some("./index.js")),
vec!["load.js", "node_modules/.pnpm/dep/index.js", "src/webpack/index.js"]
);
assert!(
module_scripts(&tree, None).is_empty(),
"without a JS entry nothing imports the scripts"
);

let single = root.join("single");
std::fs::create_dir_all(&single).expect("single dir");
std::fs::write(single.join("index.js"), "").expect("single entry");
assert!(module_scripts(&single, Some("index.js")).is_empty());
std::fs::remove_dir_all(root).expect("cleanup");
}

#[test]
fn stamps_verified_support_into_the_loader_manifest() {
let manifest = ModulesManifest {
Expand Down
39 changes: 39 additions & 0 deletions src/jsHelper/modularLoader/adopt-css.test.mts
Original file line number Diff line number Diff line change
@@ -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 <style> fallbacks after extension ones", () => {
document.head.replaceChildren();
adoptCss("@import url(x.css); .theme {}", { theme: true });
adoptCss("@import url(y.css); .alpha {}");
assert.deepEqual(
[...document.head.querySelectorAll("style")].map((el) => el.textContent.split("; ")[1]),
[".alpha {}", ".theme {}"],
);
});
});
19 changes: 19 additions & 0 deletions src/jsHelper/modularLoader/boot-order.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
46 changes: 40 additions & 6 deletions src/jsHelper/modularLoader/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,31 @@ async function cssFromSource(text: string): Promise<CSSStyleSheet | string> {
return text;
}

const prefetchedCss = new Map<string, Promise<string>>();

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());
// loadCss reports a failed prefetch when it falls back to its own fetch.
text.catch(() => {});
prefetchedCss.set(path, text);
}

async function loadCss(path: string): Promise<CSSStyleSheet | string> {
const res = await fetch(path);
return cssFromSource(await res.text());
const prefetched = prefetchedCss.get(path);
prefetchedCss.delete(path);
const text = await prefetched?.catch((e: unknown) => {
log("error")(`prefetch of ${path} failed; fetching again`, e);
return undefined;
});
return cssFromSource(text ?? (await (await fetch(path)).text()));
}

// parseColorSchemes parses classic spicetify color.ini into named
Expand Down Expand Up @@ -276,16 +298,25 @@ async function applyScheme(identifier: string, source?: string): Promise<(() =>
};
}

function adoptCss(sheet: unknown): () => void {
const themeSheets = new Set<CSSStyleSheet>();

// Theme sheets stay after every other module sheet, so a theme overrides
// extension styles at equal specificity whatever order modules load in.
export function adoptCss(sheet: unknown, options: { theme?: boolean } = {}): () => void {
if (sheet instanceof CSSStyleSheet) {
document.adoptedStyleSheets.push(sheet);
const sheets = document.adoptedStyleSheets;
const at = options.theme ? -1 : sheets.findIndex((s) => themeSheets.has(s));
if (options.theme) themeSheets.add(sheet);
document.adoptedStyleSheets = at < 0 ? [...sheets, sheet] : [...sheets.slice(0, at), sheet, ...sheets.slice(at)];
return () => {
themeSheets.delete(sheet);
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== sheet);
};
}
const el = document.createElement("style");
el.textContent = String(sheet);
document.head.appendChild(el);
if (options.theme) el.dataset.spicetifyTheme = "";
document.head.insertBefore(el, options.theme ? null : document.head.querySelector("style[data-spicetify-theme]"));
return () => el.remove();
}

Expand Down Expand Up @@ -383,7 +414,7 @@ async function waitForClient(timeoutMs: number): Promise<boolean> {
// 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,
Expand Down Expand Up @@ -486,6 +517,7 @@ async function boot(): Promise<BootReport | null> {
importSource,
loadCss,
cssFromSource,
prefetch,
adoptCss,
applyScheme,
activeThemePref: {
Expand All @@ -508,12 +540,14 @@ async function boot(): Promise<BootReport | null> {
(globalThis as never as Record<string, unknown>).CHUNKS ??= {};
await registry.runMixins(report);
await bootClient(transforms);
registry.prefetchLoads(report);

if (!(await waitForClient(15000))) {
log("error")("client did not come up in time; running module loads anyway");
}
await captureWebpackRequire();
await registry.runLoads(report);
prefetchedCss.clear();

globalThis.Spicetify = globalThis.Spicetify ?? {};
const modules = (globalThis.Spicetify as Record<string, unknown>).Modules = {
Expand Down
77 changes: 77 additions & 0 deletions src/jsHelper/modularLoader/registry.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,80 @@ 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<string, string> = {}) =>
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"]);
});

it("prefetches only the staged entries boot will import or adopt", async () => {
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" },
preload: ["load.js", "src/util.js"],
}),
mod("off", "1.0.0"),
mod("needs-off", "1.0.0", { dependencies: { off: "^1.0.0" } }),
mod("after-needs-off", "1.0.0", { dependencies: { "needs-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": "" } });
const report = { loaded: [], failed: {} };
await r.runMixins(report);
r.prefetchLoads(report);
assert.deepEqual(prefetched, [
"js:/modules/theme-b/index.js",
"css:/modules/theme-b/index.css",
"js:/modules/stdlib/index.js",
"js:/modules/mixer/load.js",
"js:/modules/mixer/src/util.js",
"css:/modules/mixer/index.css",
]);
});

it("looks up a color scheme only for themes", async () => {
const schemes: string[] = [];
const effects = {
...trackingEffects([]),
applyScheme: async (id: string) => {
schemes.push(id);
return null;
},
};
const r = new Registry(
manifest([mod("styled", "1.0.0", { entries: { js: "index.js", css: "index.css" } }), theme("theme-a")]),
effects,
);
const report = await r.boot();
assert.deepEqual(report.failed, {});
assert.deepEqual(schemes, ["theme-a"]);
await r.unload("styled");
await r.enable("styled", report);
assert.deepEqual(schemes, ["theme-a"]);
});
});
Loading
Loading