Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/hooks/use-global-obj-loader.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/utils/get-3d-model-cache-key.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
93 changes: 93 additions & 0 deletions tests/get-3d-model-cache-key.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})