diff --git a/modules/stdlib/metadata.json b/modules/stdlib/metadata.json index c63f563..ac19e3a 100644 --- a/modules/stdlib/metadata.json +++ b/modules/stdlib/metadata.json @@ -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", diff --git a/modules/stdlib/src/util.test.mts b/modules/stdlib/src/util.test.mts index f56d270..957dc3e 100644 --- a/modules/stdlib/src/util.test.mts +++ b/modules/stdlib/src/util.test.mts @@ -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 @@ -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"); +}); diff --git a/modules/stdlib/src/util.ts b/modules/stdlib/src/util.ts index 7b36ced..c815563 100644 --- a/modules/stdlib/src/util.ts +++ b/modules/stdlib/src/util.ts @@ -14,9 +14,10 @@ type Predicate = (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); @@ -26,18 +27,32 @@ const safeString = (x: any): string => { } }; +const sources = new WeakMap(); + +// 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>) { const testFns = tests.map((test): Predicate => { 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 (xs: A[]) => xs.find(testFn)!; } diff --git a/modules/stdlib/src/webpack/ComponentLibrary.xpui.ts b/modules/stdlib/src/webpack/ComponentLibrary.xpui.ts index 531668e..91ccad5 100644 --- a/modules/stdlib/src/webpack/ComponentLibrary.xpui.ts +++ b/modules/stdlib/src/webpack/ComponentLibrary.xpui.ts @@ -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); diff --git a/modules/stdlib/src/webpack/index.ts b/modules/stdlib/src/webpack/index.ts index 76d5848..030ca5e 100644 --- a/modules/stdlib/src/webpack/index.ts +++ b/modules/stdlib/src/webpack/index.ts @@ -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>; @@ -18,19 +20,7 @@ export let exportedContexts: Array>; export let exportedForwardRefs: Array; 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]>; @@ -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 @@ -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 @@ -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"), diff --git a/modules/stdlib/src/webpack/registry-quiet.test.mts b/modules/stdlib/src/webpack/registry-quiet.test.mts new file mode 100644 index 0000000..cba9e08 --- /dev/null +++ b/modules/stdlib/src/webpack/registry-quiet.test.mts @@ -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); +}); diff --git a/modules/stdlib/src/webpack/registry-quiet.ts b/modules/stdlib/src/webpack/registry-quiet.ts new file mode 100644 index 0000000..9e0b56f --- /dev/null +++ b/modules/stdlib/src/webpack/registry-quiet.ts @@ -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); +} diff --git a/packages/kit/package.json b/packages/kit/package.json index 9c8dab3..048bfc3 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -40,7 +40,7 @@ "access": "public" }, "spicetify": { - "stdlibVersion": "1.13.1" + "stdlibVersion": "1.13.2" }, "scripts": { "build": "tsc -p tsconfig.build.json",