Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(shadcn): cache registry calls #6732

Merged
merged 2 commits into from
Feb 22, 2025
Merged
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
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
Loading