Skip to content

Commit

Permalink
feat(shadcn): cache registry calls (#6732)
Browse files Browse the repository at this point in the history
* fix(shadcn): cache registry calls

* chore: changeset
  • Loading branch information
shadcn authored Feb 22, 2025
1 parent 32f0bc0 commit 839afa7
Show file tree
Hide file tree
Showing 5 changed files with 525 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-eels-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"shadcn": patch
---

cache registry calls
1 change: 1 addition & 0 deletions packages/shadcn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"fs-extra": "^11.1.0",
"https-proxy-agent": "^6.2.0",
"kleur": "^4.1.5",
"msw": "^2.7.1",
"node-fetch": "^3.3.0",
"ora": "^6.1.2",
"postcss": "^8.4.24",
Expand Down
114 changes: 114 additions & 0 deletions packages/shadcn/src/registry/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { HttpResponse, http } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"

import { clearRegistryCache, fetchRegistry } from "./api"

const REGISTRY_URL = "https://ui.shadcn.com/r"

const server = setupServer(
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, () => {
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content",
type: "registry:ui",
},
],
})
}),
http.get(`${REGISTRY_URL}/styles/new-york/card.json`, () => {
return HttpResponse.json({
name: "card",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/card.tsx",
content: "// card component content",
type: "registry:ui",
},
],
})
})
)

beforeAll(() => server.listen())
afterEach(() => {
server.resetHandlers()
})
afterAll(() => server.close())

describe("fetchRegistry", () => {
it("should fetch registry data", async () => {
const paths = ["styles/new-york/button.json"]
const result = await fetchRegistry(paths)

expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
})
})

it("should use cache for subsequent requests", async () => {
const paths = ["styles/new-york/button.json"]
let fetchCount = 0

// Clear any existing cache before test
clearRegistryCache()

// Define the handler with counter before making requests
server.use(
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
// Add a small delay to simulate network latency
await new Promise((resolve) => setTimeout(resolve, 10))
fetchCount++
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content",
type: "registry:ui",
},
],
})
})
)

// First request
const result1 = await fetchRegistry(paths)
expect(fetchCount).toBe(1)
expect(result1).toHaveLength(1)
expect(result1[0]).toMatchObject({ name: "button" })

// Second request - should use cache
const result2 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result2).toHaveLength(1)
expect(result2[0]).toMatchObject({ name: "button" })

// Third request - double check cache
const result3 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result3).toHaveLength(1)
expect(result3[0]).toMatchObject({ name: "button" })
})

it("should handle multiple paths", async () => {
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
const result = await fetchRegistry(paths)

expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
})
96 changes: 57 additions & 39 deletions packages/shadcn/src/registry/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const agent = process.env.https_proxy
? new HttpsProxyAgent(process.env.https_proxy)
: undefined

const registryCache = new Map<string, Promise<any>>()

export async function getRegistryIndex() {
try {
const [result] = await fetchRegistry(["index.json"])
Expand Down Expand Up @@ -176,52 +178,64 @@ export async function fetchRegistry(paths: string[]) {
const results = await Promise.all(
paths.map(async (path) => {
const url = getRegistryUrl(path)
const response = await fetch(url, { agent })

if (!response.ok) {
const errorMessages: { [key: number]: string } = {
400: "Bad request",
401: "Unauthorized",
403: "Forbidden",
404: "Not found",
500: "Internal server error",
}

if (response.status === 401) {
throw new Error(
`You are not authorized to access the component at ${highlighter.info(
url
)}.\nIf this is a remote registry, you may need to authenticate.`
)
}

if (response.status === 404) {
throw new Error(
`The component at ${highlighter.info(
url
)} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.`
)
}
// Check cache first
if (registryCache.has(url)) {
return registryCache.get(url)
}

if (response.status === 403) {
// Store the promise in the cache before awaiting
const fetchPromise = (async () => {
const response = await fetch(url, { agent })

if (!response.ok) {
const errorMessages: { [key: number]: string } = {
400: "Bad request",
401: "Unauthorized",
403: "Forbidden",
404: "Not found",
500: "Internal server error",
}

if (response.status === 401) {
throw new Error(
`You are not authorized to access the component at ${highlighter.info(
url
)}.\nIf this is a remote registry, you may need to authenticate.`
)
}

if (response.status === 404) {
throw new Error(
`The component at ${highlighter.info(
url
)} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.`
)
}

if (response.status === 403) {
throw new Error(
`You do not have access to the component at ${highlighter.info(
url
)}.\nIf this is a remote registry, you may need to authenticate or a token.`
)
}

const result = await response.json()
const message =
result && typeof result === "object" && "error" in result
? result.error
: response.statusText || errorMessages[response.status]
throw new Error(
`You do not have access to the component at ${highlighter.info(
url
)}.\nIf this is a remote registry, you may need to authenticate or a token.`
`Failed to fetch from ${highlighter.info(url)}.\n${message}`
)
}

const result = await response.json()
const message =
result && typeof result === "object" && "error" in result
? result.error
: response.statusText || errorMessages[response.status]
throw new Error(
`Failed to fetch from ${highlighter.info(url)}.\n${message}`
)
}
return response.json()
})()

return response.json()
registryCache.set(url, fetchPromise)
return fetchPromise
})
)

Expand All @@ -233,6 +247,10 @@ export async function fetchRegistry(paths: string[]) {
}
}

export function clearRegistryCache() {
registryCache.clear()
}

export async function registryResolveItemsTree(
names: z.infer<typeof registryItemSchema>["name"][],
config: Config
Expand Down
Loading

0 comments on commit 839afa7

Please sign in to comment.