Skip to content
2 changes: 1 addition & 1 deletion modules/stdlib/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"kind": "lib",
"hidden": true,
"preview": "./assets/PREVIEW.png",
"version": "1.13.1",
"version": "1.13.2",
"compat": ["0.3.0"],
"authors": ["spicetify", "Delusoire"],
"description": "The standard library",
Expand Down
28 changes: 26 additions & 2 deletions modules/stdlib/src/util.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
*/

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { describe, it, test } from "node:test";

import { findBy, matchLast, toPascalCase } from "./util.ts";
import { findBy, matchLast, sourceOf, toPascalCase } from "./util.ts";

describe("toPascalCase", () => {
// Golden values computed against the original hooks-era data-URL
Expand Down Expand Up @@ -95,3 +95,27 @@ describe("matchLast", () => {
assert.equal(m[1], "c");
});
});

test("findBy stops at the first failing test and stringifies each candidate once", () => {
let stringified = 0;
let laterTests = 0;
const candidate = { toString: () => (stringified++, "alpha beta") };
const other = { toString: () => (stringified++, "gamma") };
const find = findBy("gamma", (x) => (laterTests++, x === other), /gamma/);
assert.equal(find([candidate, other]), other);
assert.equal(laterTests, 1);
assert.equal(stringified, 2);
assert.equal(find([candidate, other]), other);
assert.equal(stringified, 2);
});

test("sourceOf caches function source and maps nullish values to an empty string", () => {
let calls = 0;
const fn = Object.assign(() => {}, { toString: () => (calls++, "source") });
assert.equal(sourceOf(fn), "source");
assert.equal(sourceOf(fn), "source");
assert.equal(calls, 1);
assert.equal(sourceOf(null), "");
assert.equal(sourceOf(undefined), "");
assert.equal(sourceOf(42), "42");
});
25 changes: 20 additions & 5 deletions modules/stdlib/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ type Predicate<A> = (value: A) => boolean;

// Some client exports are functions whose own toString is not callable;
// they can never match a needle, so they stringify to "".
const safeString = (x: any): string => {
const stringify = (x: any): string => {
if (x == null) return "";
try {
return x.toString();
return String(x);
} catch {
try {
return Function.prototype.toString.call(x);
Expand All @@ -26,18 +27,32 @@ const safeString = (x: any): string => {
}
};

const sources = new WeakMap<object, string>();

// sourceOf is the source text needles match against, computed once per
// function or object for the whole session.
export const sourceOf = (x: unknown): string => {
if ((typeof x !== "object" && typeof x !== "function") || x === null) return stringify(x);
let source = sources.get(x);
if (source === undefined) {
source = stringify(x);
sources.set(x, source);
}
return source;
};

export function findBy(...tests: Array<string | RegExp | Predicate<any>>) {
const testFns = tests.map((test): Predicate<any> => {
switch (typeof test) {
case "string":
return (x) => safeString(x).includes(test);
return (x) => sourceOf(x).includes(test);
case "function":
return (x) => test(x);
default: // assume regex
return (x) => test.test(safeString(x));
return (x) => test.test(sourceOf(x));
}
});
const testFn = (x: any) => testFns.map((t) => t(x)).every(Boolean);
const testFn = (x: any) => testFns.every((t) => t(x));
return <A>(xs: A[]) => xs.find(testFn)!;
}

Expand Down
14 changes: 12 additions & 2 deletions modules/stdlib/src/webpack/ComponentLibrary.xpui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ await CHUNKS.xpui.promise;
const componentNames = Object.keys(
exported.find((e) => e.BrowserDefaultFocusStyleProvider && Object.hasOwn(e, "BrowserDefaultFocusStyleProvider")),
);
const componentRegexes = componentNames.map((n) => new RegExp(`"data-encore-id":(?:[a-zA-Z_\$][\w\$]*\\.){2}${n}\\b`));
const componentRegexes = componentNames.map(
(n) => new RegExp(String.raw`"data-encore-id":(?:[a-zA-Z_$][\w$]*\.){2}${n}\b`),
);
const componentPairs = [exportedFunctions.map((f) => [f, f]), exportedForwardRefs.map((f) => [(f as any).render, f])]
.flat()
.map(([s, f]) => [componentNames.find((n, i) => src(s).match(componentRegexes[i])), f]);
.map(([s, f]) => {
const code = src(s);
return [
code.includes('"data-encore-id"')
? componentNames.find((_, i) => componentRegexes[i].test(code))
: undefined,
f,
];
});

export const UI: any = Object.fromEntries(componentPairs);
76 changes: 37 additions & 39 deletions modules/stdlib/src/webpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
*/

import { warn } from "../logger.ts";
import { sourceOf } from "../util.ts";
import { postWebpackRequireHooks, WebpackModule, WebpackRequire, webpackRequire } from "../wpunpk.mix.ts";
import { createCaptureReadiness } from "./capture-readiness.ts";
import { watchRegistryQuiet } from "./registry-quiet.ts";

export let modules: Array<[PropertyKey, WebpackModule]>;
export let exports: Array<Record<string, any>>;
Expand All @@ -18,19 +20,7 @@ export let exportedContexts: Array<React.Context<any>>;
export let exportedForwardRefs: Array<any>;
export let exportedMemos: React.NamedExoticComponent[];

// Some client exports are functions whose own toString is not callable; they
// can never match a needle, so they stringify to "".
export const src = (f: unknown): string => {
try {
return String(f);
} catch {
try {
return Function.prototype.toString.call(f);
} catch {
return "";
}
}
};
export const src = sourceOf;

export const analyzeWebpackRequire = (webpackRequire: WebpackRequire) => {
const modules = Object.entries(webpackRequire.m) as Array<[keyof any, WebpackModule]>;
Expand Down Expand Up @@ -84,18 +74,31 @@ Object.assign(CHUNKS, {
// executed by capture time. Lazy route chunks go through the runtime's own
// script loader (wpr.l) from then on.
postWebpackRequireHooks.push((wpr: any) => {
let loading = 0;
if (typeof wpr?.l === "function") {
const load = wpr.l.bind(wpr);
wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) =>
load(
url,
(event: unknown) => {
resolveChunk(new URL(url, location.href).pathname);
return done(event);
},
key,
chunkId,
);
wpr.l = (url: string, done: (event: unknown) => unknown, key?: string, chunkId?: unknown) => {
loading++;
try {
return load(
url,
(event: unknown) => {
loading--;
try {
resolveChunk(new URL(url, location.href).pathname);
} catch (error) {
console.error(`[stdlib] could not resolve chunk ${url}:`, error);
}
return done(event);
},
key,
chunkId,
);
} catch (error) {
loading--;
throw error;
}
};
}
// Chunk scripts that finished loading before capture. The xpui entry
// chunks are deliberately excluded: they are gated below on registry
Expand All @@ -108,23 +111,18 @@ postWebpackRequireHooks.push((wpr: any) => {
}
}
// Capture can fire while boot is still registering modules (xpui-modules
// and friends land after the runtime is up, and the analysis needles live
// there). The analysis below snapshots wpr.m once, so hold the xpui
// promises until the registry has been quiet for a few ticks — with a
// hard cap so a pathological boot still resolves.
let last = -1;
let stable = 0;
let ticks = 0;
const settle = setInterval(() => {
const count = Object.keys(wpr?.m ?? {}).length;
stable = count === last ? stable + 1 : 0;
last = count;
if (stable >= 3 || ++ticks > 100) {
clearInterval(settle);
// and the lazy chunks the client requests right after it land later, and
// analysis needles such as GenericModal live there). The analysis below
// snapshots wpr.m once, so hold the xpui promises until the registry has
// been quiet, with no chunk in flight, for 300ms.
watchRegistryQuiet(
() => Object.keys(wpr?.m ?? {}).length,
() => loading,
() => {
resolveChunk("/vendor~xpui.js");
resolveChunk("/xpui.js");
}
}, 100);
},
);
});

// Capture subscribers run synchronously right after the analysis lands, so
Expand All @@ -135,7 +133,7 @@ postWebpackRequireHooks.push((wpr: any) => {
let captured = false;
const captureSubscribers: Array<() => void> = [];
const webpackCaptureReadiness = createCaptureReadiness({
// Registry quiescence caps at about 10.1s. Leave room for its last tick,
// Registry quiescence caps at about 10s. Leave room for its last tick,
// then release degraded so a changed runtime cannot deadlock all modules.
timeoutMs: 12000,
onTimeout: () => warn("[stdlib] capture health: webpack capture timed out; module surfaces will be degraded"),
Expand Down
47 changes: 47 additions & 0 deletions modules/stdlib/src/webpack/registry-quiet.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";

import { watchRegistryQuiet } from "./registry-quiet.ts";

const setup = (t: TestContext) => {
t.mock.timers.enable({ apis: ["setInterval", "Date"] });
const state = { count: 100, pending: 0, quietAt: -1 };
watchRegistryQuiet(
() => state.count,
() => state.pending,
() => (state.quietAt = Date.now()),
);
const advance = (ms: number) => {
for (let i = 0; i < ms / 25; i++) t.mock.timers.tick(25);
};
return { state, advance };
};

test("releases once the registry has been unchanged for 300ms", (t) => {
const { state, advance } = setup(t);
advance(200);
state.count = 150;
advance(275);
assert.equal(state.quietAt, -1);
advance(50);
assert.equal(state.quietAt, 525);
});

test("never releases while a chunk load is in flight, even with no new modules", (t) => {
const { state, advance } = setup(t);
state.pending = 1;
advance(5000);
assert.equal(state.quietAt, -1);
state.pending = 0;
advance(300);
assert.equal(state.quietAt, 5300);
});

test("releases at the 10s cap when a chunk never finishes", (t) => {
const { state, advance } = setup(t);
state.pending = 1;
advance(9975);
assert.equal(state.quietAt, -1);
advance(25);
assert.equal(state.quietAt, 10_000);
});
30 changes: 30 additions & 0 deletions modules/stdlib/src/webpack/registry-quiet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 Afonso Jorge Ramos
* SPDX-License-Identifier: GPL-3.0-or-later
*/

const QUIET_MS = 300;
const TICK_MS = 25;
const CAP_MS = 10_000;

// watchRegistryQuiet calls onQuiet once the module registry has not changed,
// and no chunk load has been in flight, for 300ms. A 10s cap bounds the wait
// so a chunk that never finishes, or a registry that never settles, still
// resolves.
export function watchRegistryQuiet(count: () => number, pending: () => number, onQuiet: () => void): void {
const started = Date.now();
let last = -1;
let quietSince = started;
const timer = setInterval(() => {
const current = count();
const at = Date.now();
if (current !== last || pending() > 0) {
last = current;
quietSince = at;
}
if (at - quietSince >= QUIET_MS || at - started >= CAP_MS) {
clearInterval(timer);
onQuiet();
}
}, TICK_MS);
}
2 changes: 1 addition & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"access": "public"
},
"spicetify": {
"stdlibVersion": "1.13.1"
"stdlibVersion": "1.13.2"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
Expand Down
Loading