From 3d178aa1dac3a86eac83bbeb3b3de7c635e5fe58 Mon Sep 17 00:00:00 2001 From: orange-bot Date: Fri, 16 Oct 2026 23:07:01 +0800 Subject: [PATCH] fix(manage): inspect each dependency once so the Mod list cannot hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency health check walked every dependency path. A Mod that several Mods depend on (a shared helper layer) is reachable through all of them, so the walk grew with the number of paths: opening the 管理 Mod page ran it for every Mod several times per render, which freezes the window for seconds or minutes on a large Mods folder. - Visit each Mod at most once per check (the visited set still stops cycles). - Only walk when the health filter actually asks for it; the default view does not need the result. Measured on a Mods folder of 300 Mods with a 20-Mod helper layer (each helper depending on the next two): 2.2-3.3s per render before, 0.1-12ms after; with three dependencies per helper the old code did not finish in 4 minutes. --- src/celemod-ui/src/manageDependencies.test.ts | 82 +++++++++++++++++++ src/celemod-ui/src/stores/manage.ts | 31 ++++--- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/src/celemod-ui/src/manageDependencies.test.ts b/src/celemod-ui/src/manageDependencies.test.ts index 697551e..5b9255e 100644 --- a/src/celemod-ui/src/manageDependencies.test.ts +++ b/src/celemod-ui/src/manageDependencies.test.ts @@ -5,6 +5,7 @@ import { collectDuplicateFixTargets, profileDisabledFilesAfterSwitch, collectSwitchNames, + getDependencyHealth, normalizeManageDependencies, selectVisibleRootNames, type ManageNode, @@ -359,3 +360,84 @@ test("disabling a remaining file does not discard its exclusion", () => { ["Remaining.zip"], ); }); + +/** + * A shared helper layer makes the same Mod reachable through many dependency + * paths. Walking all of them grows with the number of paths, so the counts + * below are read through a proxy: a Mod list that walks each Mod once stays + * linear, one that walks every path does not finish. + */ +const pathHeavyMods = (libs: number, fanOut: number) => { + const nodes: Record = {}; + for (let index = 0; index < libs; index += 1) { + const dependencies: ManageNode["dependencies"] = []; + for (let step = 1; step <= fanOut; step += 1) { + if (index + step >= libs) break; + dependencies.push({ + name: `Lib.${index + step}`, + version: "1.0.0", + optional: false, + }); + } + nodes[`Lib.${index}`] = node(`Lib.${index}`, { dependencies }); + } + for (let index = 0; index < 20; index += 1) { + nodes[`Mod.${index}`] = node(`Mod.${index}`, { + dependencies: [ + { name: "Lib.0", version: "1.0.0", optional: false }, + { name: `Lib.${index % libs}`, version: "1.0.0", optional: false }, + ], + }); + } + return nodes; +}; + +const countingNodes = (nodes: Record) => { + const state = { reads: 0 }; + const counted = new Proxy(nodes, { + get(target, property, receiver) { + if (typeof property === "string") state.reads += 1; + return Reflect.get(target, property, receiver); + }, + }); + return { counted, state }; +}; + +test("inspects each dependency once when many paths reach it", () => { + const { counted, state } = countingNodes(pathHeavyMods(40, 3)); + + assert.deepEqual(getDependencyHealth("Lib.0", counted, false), { + status: "healthy", + messages: [], + }); + assert.ok( + state.reads < 500, + `expected a linear walk, read Mods ${state.reads} times`, + ); +}); + +test("does not walk dependencies when the health filter is off", () => { + const { counted, state } = countingNodes(pathHeavyMods(40, 3)); + + const visible = selectVisibleRootNames({ + nodes: counted, + filters: { + query: "", + enabled: "all", + health: "all", + types: [], + updateOnly: false, + duplicateOnly: false, + showHiddenTypes: false, + }, + rootOnly: false, + includeOptional: false, + hiddenTypes: [], + updateNames: new Set(), + }); + assert.equal(visible.length, 60); + assert.ok( + state.reads < 2000, + `expected one read per Mod, read Mods ${state.reads} times`, + ); +}); diff --git a/src/celemod-ui/src/stores/manage.ts b/src/celemod-ui/src/stores/manage.ts index 7f9fc37..fcbab59 100644 --- a/src/celemod-ui/src/stores/manage.ts +++ b/src/celemod-ui/src/stores/manage.ts @@ -418,7 +418,12 @@ export const getDependencyHealth = ( if (!nodes[name]?.enabled) return { status: "healthy", messages: [] }; const messages: string[] = []; let status: ManageDependencyHealth["status"] = "healthy"; - const visiting = new Set(); + // A Mod that several Mods depend on is reachable through every one of those + // paths, and walking them all grows with the number of paths, so a shared + // helper layer makes the Mod list hang. A Mod reports the same problems no + // matter which path reached it, so each one is inspected once; the same set + // still stops dependency cycles. + const visited = new Set(); const ranks = { healthy: 0, version: 1, disabled: 2, missing: 3 }; const merge = (next: ManageDependencyHealth["status"], message: string) => { @@ -427,10 +432,10 @@ export const getDependencyHealth = ( }; const visit = (nodeName: string) => { - if (visiting.has(nodeName)) return; + if (visited.has(nodeName)) return; const node = nodes[nodeName]; if (!node) return; - visiting.add(nodeName); + visited.add(nodeName); for (const dependency of node.dependencies) { if ( EXCLUDED_DEPENDENCIES.has(dependency.name) || @@ -455,7 +460,6 @@ export const getDependencyHealth = ( } visit(installed.name); } - visiting.delete(nodeName); }; visit(name); return { status, messages }; @@ -595,6 +599,9 @@ export const selectVisibleRootNames = ({ displayNames?: Record; }) => { const query = filters.query.trim().toLocaleLowerCase(); + // "All" is the default state of the filter, and the dependency walk it does + // not need is the expensive part of every Mod-list render. + const needsHealth = filters.health !== "all"; const matches = (node: ManageNode) => { if ( !filters.showHiddenTypes && @@ -611,13 +618,15 @@ export const selectVisibleRootNames = ({ if (filters.enabled === "disabled" && node.enabled) return false; if (filters.updateOnly && !updateNames.has(node.name)) return false; if (filters.duplicateOnly && node.duplicateFiles.length < 2) return false; - const health = getDependencyHealth(node.name, nodes, includeOptional); - if (filters.health === "healthy" && health.status !== "healthy") - return false; - if (filters.health === "issues" && health.status === "healthy") - return false; - if (filters.health === "missing" && health.status !== "missing") - return false; + if (needsHealth) { + const health = getDependencyHealth(node.name, nodes, includeOptional); + if (filters.health === "healthy" && health.status !== "healthy") + return false; + if (filters.health === "issues" && health.status === "healthy") + return false; + if (filters.health === "missing" && health.status !== "missing") + return false; + } if (query) { const text = [ node.name,