From 778201dd2779ae15b05c89f832ee76d85548aaf7 Mon Sep 17 00:00:00 2001 From: leninug Date: Thu, 21 May 2026 15:33:59 +0700 Subject: [PATCH] fix: normalize filled cachebust_origin values in model cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract URL cache-key normalization into a pure `get3DModelCacheKey` utility and route `useGlobalObjLoader` through it. Before this change the cache-busting strip only matched the trailing empty form `&cachebust_origin=`. Real production fixtures (see `stories/assets/complex-board.json`, `nine-key-keyboard.json`) send the filled form `&cachebust_origin=https%3A%2F%2Ftscircuit.com`, which slipped through unchanged. Two boards referencing the same underlying model from different origins produced different cache keys, so the same OBJ was fetched, parsed, and cloned twice — the exact duplicate- load pattern described in #93. The new utility handles every position of the parameter (first / middle / last / only-param), with or without a value, and preserves the URL fragment. Wire-up in `useGlobalObjLoader` is a one-line swap. 13 unit tests cover empty / filled / first / middle / trailing / hash-bearing / lookalike-prefix cases plus a real production fixture URL, and assert that two URLs differing only in `cachebust_origin` collapse to the same key. All existing tests continue to pass. --- src/hooks/use-global-obj-loader.ts | 3 +- src/utils/get-3d-model-cache-key.ts | 39 ++++++++++++ tests/get-3d-model-cache-key.test.ts | 93 ++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/utils/get-3d-model-cache-key.ts create mode 100644 tests/get-3d-model-cache-key.test.ts diff --git a/src/hooks/use-global-obj-loader.ts b/src/hooks/use-global-obj-loader.ts index 69422095..fe91ee21 100644 --- a/src/hooks/use-global-obj-loader.ts +++ b/src/hooks/use-global-obj-loader.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from "react" import type { Object3D } from "three" import { MTLLoader, OBJLoader } from "three-stdlib" +import { get3DModelCacheKey } from "src/utils/get-3d-model-cache-key" import { loadVrml } from "src/utils/vrml" // Define the type for our cache @@ -28,7 +29,7 @@ export function useGlobalObjLoader( useEffect(() => { if (!url) return - const cleanUrl = url.replace(/&cachebust_origin=$/, "") + const cleanUrl = get3DModelCacheKey(url) const cache = window.TSCIRCUIT_OBJ_LOADER_CACHE let hasUrlChanged = false diff --git a/src/utils/get-3d-model-cache-key.ts b/src/utils/get-3d-model-cache-key.ts new file mode 100644 index 00000000..c1a375ed --- /dev/null +++ b/src/utils/get-3d-model-cache-key.ts @@ -0,0 +1,39 @@ +/** + * Returns a normalized cache key for a 3D model URL. + * + * Strips the `cachebust_origin` query parameter wherever it appears in the + * query string, regardless of whether it has a value. `cachebust_origin` + * records the consumer's origin and is not meaningful to the underlying + * model — two URLs that differ only in `cachebust_origin` must hit the + * same cache entry, otherwise the same model is fetched and parsed twice. + * + * Without this normalization the in-place regex previously used in + * `useGlobalObjLoader` only matched the trailing empty form + * (`&cachebust_origin=`) and missed the filled form + * (`&cachebust_origin=https%3A%2F%2Ftscircuit.com`), which is what real + * fixtures send (see `stories/assets/complex-board.json`, + * `nine-key-keyboard.json`, etc.). + */ +export function get3DModelCacheKey(url: string): string { + if (!url) return url + + const hashIndex = url.indexOf("#") + const hash = hashIndex >= 0 ? url.slice(hashIndex) : "" + const beforeHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url + + const queryIndex = beforeHash.indexOf("?") + if (queryIndex < 0) return beforeHash + hash + + const base = beforeHash.slice(0, queryIndex) + const query = beforeHash.slice(queryIndex + 1) + + const filteredQuery = query + .split("&") + .filter( + (pair) => + pair !== "cachebust_origin" && !pair.startsWith("cachebust_origin="), + ) + .join("&") + + return filteredQuery ? `${base}?${filteredQuery}${hash}` : `${base}${hash}` +} diff --git a/tests/get-3d-model-cache-key.test.ts b/tests/get-3d-model-cache-key.test.ts new file mode 100644 index 00000000..439067d8 --- /dev/null +++ b/tests/get-3d-model-cache-key.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import { get3DModelCacheKey } from "../src/utils/get-3d-model-cache-key" + +test("returns the original URL when no cachebust_origin is present", () => { + expect(get3DModelCacheKey("https://cdn.com/model.obj")).toBe( + "https://cdn.com/model.obj", + ) +}) + +test("strips trailing cachebust_origin with empty value", () => { + expect( + get3DModelCacheKey("https://cdn.com/model.obj?uuid=abc&cachebust_origin="), + ).toBe("https://cdn.com/model.obj?uuid=abc") +}) + +test("strips trailing cachebust_origin with a URL-encoded value", () => { + expect( + get3DModelCacheKey( + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=https%3A%2F%2Ftscircuit.com", + ), + ).toBe("https://cdn.com/model.obj?uuid=abc") +}) + +test("strips cachebust_origin when it is the only query parameter", () => { + expect( + get3DModelCacheKey("https://cdn.com/model.obj?cachebust_origin=foo"), + ).toBe("https://cdn.com/model.obj") +}) + +test("strips cachebust_origin from the start of the query and preserves following params", () => { + expect( + get3DModelCacheKey( + "https://cdn.com/model.obj?cachebust_origin=foo&uuid=abc", + ), + ).toBe("https://cdn.com/model.obj?uuid=abc") +}) + +test("strips cachebust_origin from the middle of the query", () => { + expect( + get3DModelCacheKey( + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=foo&pn=C123", + ), + ).toBe("https://cdn.com/model.obj?uuid=abc&pn=C123") +}) + +test("preserves URL fragment when stripping cachebust_origin", () => { + expect( + get3DModelCacheKey( + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=foo#section1", + ), + ).toBe("https://cdn.com/model.obj?uuid=abc#section1") +}) + +test("does not strip parameters with similar prefixes", () => { + expect( + get3DModelCacheKey( + "https://cdn.com/model.obj?cachebust_origin_extra=foo&cachebust=bar", + ), + ).toBe("https://cdn.com/model.obj?cachebust_origin_extra=foo&cachebust=bar") +}) + +test("returns the same value for two URLs that differ only in cachebust_origin", () => { + const a = + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=https%3A%2F%2Fa.com" + const b = + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=https%3A%2F%2Fb.com" + expect(get3DModelCacheKey(a)).toBe(get3DModelCacheKey(b)) +}) + +test("returns the same value for trailing-empty vs filled cachebust_origin", () => { + const a = "https://cdn.com/model.obj?uuid=abc&cachebust_origin=" + const b = + "https://cdn.com/model.obj?uuid=abc&cachebust_origin=https%3A%2F%2Ftscircuit.com" + expect(get3DModelCacheKey(a)).toBe(get3DModelCacheKey(b)) +}) + +test("returns empty string when input is empty", () => { + expect(get3DModelCacheKey("")).toBe("") +}) + +test("normalizes a real production URL from complex-board fixture", () => { + const url = + "https://modelcdn.tscircuit.com/easyeda_models/download?uuid=229b69761e2c45dba6a83d8866dec72d&pn=C61423&cachebust_origin=https%3A%2F%2Ftscircuit.com" + const expected = + "https://modelcdn.tscircuit.com/easyeda_models/download?uuid=229b69761e2c45dba6a83d8866dec72d&pn=C61423" + expect(get3DModelCacheKey(url)).toBe(expected) +}) + +test("handles cachebust_origin appearing as a value-less parameter", () => { + expect( + get3DModelCacheKey("https://cdn.com/model.obj?uuid=abc&cachebust_origin"), + ).toBe("https://cdn.com/model.obj?uuid=abc") +})