From c682a13e544a73a684ecab19a7a314bf77e643cd Mon Sep 17 00:00:00 2001 From: Pixnop Date: Sat, 5 Sep 2026 12:13:25 +0200 Subject: [PATCH 1/3] fix(mods): read only string entries from mod database lists The detail endpoint returns a null among Vanilla Variants' category tags. The Manage Mods filter derivations folded case on every entry during the first render, so null.toLowerCase() threw, and with no error boundary the whole page went blank for anyone with that mod installed (#370). Every reader in installedFilters.ts now goes through one helper that keeps string entries only and reads a malformed list as empty, so a shape the server does not honour can no longer take the page down. --- src/domain/mods/installedFilters.ts | 31 +++++++++++++---- tests/domain/mods/installedFilters.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/domain/mods/installedFilters.ts b/src/domain/mods/installedFilters.ts index ee0401df..66941c89 100644 --- a/src/domain/mods/installedFilters.ts +++ b/src/domain/mods/installedFilters.ts @@ -24,6 +24,25 @@ export const NO_INSTALLED_MOD_FILTERS: InstalledModFilters = { author: "", tags: /** The shape a ModDB game-version release tag has: a dotted number, never a leading "v". */ const GAME_VERSION_TAG = /^\d+(\.\d+)+/ +/** + * Keeps only the string entries of a list handed to us by the mod database or a modinfo. + * + * The API's documented shape says `tags` is a list of strings. The live detail endpoint returns + * `["Cosmetics", "Crafting", "Storage", null]` for Vanilla Variants (#370). Every reader below folds + * case or tests a prefix, both of which throw on `null`, and one throw during the first render blanks + * the whole page. So nothing in this module trusts the shape: whatever is not a string is dropped, and + * a field that is not a list at all reads as empty. + */ +function textEntries(values: unknown): string[] { + return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string") : [] +} + +/** The releases the mod database lists for a mod, or none when the field is missing or malformed. */ +function releasesOf(mod: InstalledModType): readonly { tags?: unknown }[] { + const releases: unknown = mod._mod?.releases + return Array.isArray(releases) ? (releases as { tags?: unknown }[]) : [] +} + /** * Deduplicates ignoring case, keeping the first spelling seen. * @@ -42,7 +61,7 @@ function uniqueIgnoringCase(values: readonly string[]): string[] { /** Every author across the scan, A to Z. Read from the local modinfo, so this axis needs no network. */ export function installedModAuthors(mods: readonly InstalledModType[]): string[] { - return uniqueIgnoringCase(mods.flatMap((mod) => mod.authors ?? [])).sort((a, b) => a.localeCompare(b)) + return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod.authors))).sort((a, b) => a.localeCompare(b)) } /** @@ -52,7 +71,7 @@ export function installedModAuthors(mods: readonly InstalledModType[]): string[] * modinfo.ts shows, so this list is empty for a folder the ModDB cannot answer for. */ export function installedModTags(mods: readonly InstalledModType[]): string[] { - return uniqueIgnoringCase(mods.flatMap((mod) => mod._mod?.tags ?? [])).sort((a, b) => a.localeCompare(b)) + return uniqueIgnoringCase(mods.flatMap((mod) => textEntries(mod._mod?.tags))).sort((a, b) => a.localeCompare(b)) } /** @@ -63,7 +82,7 @@ export function installedModTags(mods: readonly InstalledModType[]): string[] { * are the category tags the other dropdown offers. */ export function installedModGameVersions(mods: readonly InstalledModType[]): string[] { - const tags = mods.flatMap((mod) => (mod._mod?.releases ?? []).flatMap((release) => release.tags ?? [])) + const tags = mods.flatMap((mod) => releasesOf(mod).flatMap((release) => textEntries(release.tags))) return Array.from(new Set(tags.filter((tag) => GAME_VERSION_TAG.test(tag)))).sort((a, b) => compareVersions(b, a)) } @@ -71,13 +90,13 @@ export function installedModGameVersions(mods: readonly InstalledModType[]): str function matchesAuthor(mod: InstalledModType, author: string): boolean { if (author === "") return true const wanted = author.toLowerCase() - return (mod.authors ?? []).some((name) => name.toLowerCase() === wanted) + return textEntries(mod.authors).some((name) => name.toLowerCase() === wanted) } /** Every selected tag has to be present, so picking a second tag narrows the list rather than widening it. */ function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean { if (tags.length < 1) return true - const modTags = (mod._mod?.tags ?? []).map((tag) => tag.toLowerCase()) + const modTags = textEntries(mod._mod?.tags).map((tag) => tag.toLowerCase()) return tags.every((tag) => modTags.includes(tag.toLowerCase())) } @@ -91,7 +110,7 @@ function matchesTags(mod: InstalledModType, tags: readonly string[]): boolean { */ function matchesGameVersion(mod: InstalledModType, gameVersion: string): boolean { if (gameVersion === "") return true - return (mod._mod?.releases ?? []).some((release) => evaluateModCompatibility(release.tags ?? [], gameVersion) !== "undeclared") + return releasesOf(mod).some((release) => evaluateModCompatibility(textEntries(release.tags), gameVersion) !== "undeclared") } /** All three axes at once. A mod clears every one of them, so each pick narrows what is left. */ diff --git a/tests/domain/mods/installedFilters.test.ts b/tests/domain/mods/installedFilters.test.ts index 8585c0cf..5165c04c 100644 --- a/tests/domain/mods/installedFilters.test.ts +++ b/tests/domain/mods/installedFilters.test.ts @@ -167,3 +167,42 @@ describe("matching one installed mod against the filters", () => { assert.equal(hasActiveInstalledModFilters(filters({ gameVersion: "1.20.0" })), true) }) }) + +describe("real mod database shapes (#370)", () => { + // The detail endpoint returns exactly this for Vanilla Variants: a null among the category tags. + // The documented shape says strings only, and the launcher used to believe it. + const nullTag = ["Cosmetics", "Crafting", "Storage", null] as unknown as string[] + const vanillaVariants = aMod("VanillaVariants", { _mod: aDetail(nullTag, [["1.21.0", "1.21.1"]]) }) + + it("collects category tags past a null in the list instead of throwing", () => { + assert.deepEqual(installedModTags([vanillaVariants]), ["Cosmetics", "Crafting", "Storage"]) + }) + + it("still matches the tags that are real when a null sits beside them", () => { + assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["storage"] }), true) + assert.equal(matchesInstalledModFilters(vanillaVariants, { ...NO_INSTALLED_MOD_FILTERS, tags: ["Food"] }), false) + }) + + it("collects game versions past a null in a release's tags", () => { + const releaseWithNull = [["1.21.0", null, "1.21.1"]] as unknown as string[][] + const mod = aMod("Odd", { _mod: aDetail(["Other"], releaseWithNull) }) + assert.deepEqual(installedModGameVersions([mod]), ["1.21.1", "1.21.0"]) + assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true) + }) + + it("ignores an author entry that is not a string", () => { + const mod = aMod("Odd", { authors: ["Ann", null, 42, "Bob"] as unknown as string[] }) + assert.deepEqual(installedModAuthors([mod]), ["Ann", "Bob"]) + assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, author: "bob" }), true) + }) + + it("reads a tags field that is not a list as no tags, and releases that are not a list as no releases", () => { + const detail = aDetail([], []) + const mod = aMod("Odd", { _mod: { ...detail, tags: "Cosmetics" as unknown as string[], releases: null as unknown as typeof detail.releases } }) + assert.deepEqual(installedModTags([mod]), []) + assert.deepEqual(installedModGameVersions([mod]), []) + assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, tags: ["cosmetics"] }), false) + assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), false) + assert.equal(filterInstalledMods([mod], NO_INSTALLED_MOD_FILTERS).length, 1) + }) +}) From a6dbaf76be6a013d7fc11e592d7ea0d20fcc0483 Mon Sep 17 00:00:00 2001 From: Pixnop Date: Sat, 5 Sep 2026 12:16:04 +0200 Subject: [PATCH 2/3] test(mods): use a non-list releases field so the array guard is pinned --- tests/domain/mods/installedFilters.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/domain/mods/installedFilters.test.ts b/tests/domain/mods/installedFilters.test.ts index 5165c04c..1a6291ff 100644 --- a/tests/domain/mods/installedFilters.test.ts +++ b/tests/domain/mods/installedFilters.test.ts @@ -198,7 +198,9 @@ describe("real mod database shapes (#370)", () => { it("reads a tags field that is not a list as no tags, and releases that are not a list as no releases", () => { const detail = aDetail([], []) - const mod = aMod("Odd", { _mod: { ...detail, tags: "Cosmetics" as unknown as string[], releases: null as unknown as typeof detail.releases } }) + // A string rather than null on purpose: `null ?? []` would read as empty by accident, and the + // guard has to hold for anything that is not a list, not only for the absent case. + const mod = aMod("Odd", { _mod: { ...detail, tags: "Cosmetics" as unknown as string[], releases: "1.21.0" as unknown as typeof detail.releases } }) assert.deepEqual(installedModTags([mod]), []) assert.deepEqual(installedModGameVersions([mod]), []) assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, tags: ["cosmetics"] }), false) From 86e2dd7637dc10792f8feca303ca87f3542aa851 Mon Sep 17 00:00:00 2001 From: Pixnop Date: Sat, 5 Sep 2026 13:31:42 +0200 Subject: [PATCH 3/3] fix(mods): skip null entries inside a mod's releases list Guarding the outer releases field let { releases: [null] } through, and release.tags then threw on the entry: the same render crash this helper exists to rule out. Each entry is now checked as well. --- src/domain/mods/installedFilters.ts | 11 +++++++++-- tests/domain/mods/installedFilters.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/domain/mods/installedFilters.ts b/src/domain/mods/installedFilters.ts index 66941c89..259ff399 100644 --- a/src/domain/mods/installedFilters.ts +++ b/src/domain/mods/installedFilters.ts @@ -37,10 +37,17 @@ function textEntries(values: unknown): string[] { return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string") : [] } -/** The releases the mod database lists for a mod, or none when the field is missing or malformed. */ +/** + * The releases the mod database lists for a mod, keeping only the entries that are objects. + * + * Guarding the outer field is not enough: `{ releases: [null] }` passes an array check and then + * `release.tags` throws on the entry, the same render crash this module exists to rule out. So each + * entry is checked too, and a missing or malformed field reads as no releases at all. + */ function releasesOf(mod: InstalledModType): readonly { tags?: unknown }[] { const releases: unknown = mod._mod?.releases - return Array.isArray(releases) ? (releases as { tags?: unknown }[]) : [] + if (!Array.isArray(releases)) return [] + return releases.filter((release): release is { tags?: unknown } => typeof release === "object" && release !== null) } /** diff --git a/tests/domain/mods/installedFilters.test.ts b/tests/domain/mods/installedFilters.test.ts index 1a6291ff..d906fcda 100644 --- a/tests/domain/mods/installedFilters.test.ts +++ b/tests/domain/mods/installedFilters.test.ts @@ -190,6 +190,14 @@ describe("real mod database shapes (#370)", () => { assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true) }) + it("skips a null entry inside an otherwise valid releases list", () => { + // { releases: [null, {...}] } passes an array check and used to throw on `release.tags`. + const detail = aDetail(["Other"], [["1.21.0"]]) + const mod = aMod("Odd", { _mod: { ...detail, releases: [null, ...detail.releases] as unknown as typeof detail.releases } }) + assert.deepEqual(installedModGameVersions([mod]), ["1.21.0"]) + assert.equal(matchesInstalledModFilters(mod, { ...NO_INSTALLED_MOD_FILTERS, gameVersion: "1.21.0" }), true) + }) + it("ignores an author entry that is not a string", () => { const mod = aMod("Odd", { authors: ["Ann", null, 42, "Bob"] as unknown as string[] }) assert.deepEqual(installedModAuthors([mod]), ["Ann", "Bob"])