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") +})