From bfa1b5e9a69a155b2f590523d50fda810bde1a9a Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Fri, 31 Jul 2026 06:17:44 -0400 Subject: [PATCH 1/4] fix(shadcn): create nested output directories in build (#11322) * fix(shadcn): create nested output directories in build Registry item names can contain path segments (e.g. "extension/foo"). Both build commands wrote /.json without creating the implied nested directories, so builds failed with ENOENT on clean checkouts where the output directory does not already exist. Create the parent directory before writing each item. Fixes #11321 * chore: format Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xq2q7jaDcyxyA6YjutStLn --------- Co-authored-by: shadcn Co-authored-by: Claude Fable 5 --- .changeset/build-nested-output-dirs.md | 5 +++ packages/shadcn/src/commands/build.test.ts | 42 +++++++++++++++++++ packages/shadcn/src/commands/build.ts | 12 ++++-- .../shadcn/src/commands/registry/build.ts | 10 +++-- 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 .changeset/build-nested-output-dirs.md diff --git a/.changeset/build-nested-output-dirs.md b/.changeset/build-nested-output-dirs.md new file mode 100644 index 00000000000..e9a0f2212c6 --- /dev/null +++ b/.changeset/build-nested-output-dirs.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +fix `shadcn build` failing with ENOENT when registry item names contain path segments (e.g. `extension/foo`) by creating nested output directories before writing diff --git a/packages/shadcn/src/commands/build.test.ts b/packages/shadcn/src/commands/build.test.ts index 5b63b61da2e..20013f284c0 100644 --- a/packages/shadcn/src/commands/build.test.ts +++ b/packages/shadcn/src/commands/build.test.ts @@ -83,6 +83,48 @@ describe("build command", () => { ], }) }) + + it("creates nested output directories for item names with path segments", async () => { + const cwd = await createFixture({ + "registry.json": JSON.stringify({ + name: "example", + homepage: "https://example.com", + items: [ + { + name: "extension/foo", + type: "registry:item", + files: [ + { + path: "registry/extensions/foo.tsx", + type: "registry:file", + target: "extensions/foo.tsx", + }, + ], + }, + ], + }), + "registry/extensions/foo.tsx": "export function Foo() {}", + }) + + await build.parseAsync( + ["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"], + { from: "node" } + ) + + const item = JSON.parse( + await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8") + ) + + expect(item).toMatchObject({ + name: "extension/foo", + files: [ + { + path: "registry/extensions/foo.tsx", + content: "export function Foo() {}", + }, + ], + }) + }) }) async function createFixture(files: Record) { diff --git a/packages/shadcn/src/commands/build.ts b/packages/shadcn/src/commands/build.ts index f6426c92693..5b9ba00cf33 100644 --- a/packages/shadcn/src/commands/build.ts +++ b/packages/shadcn/src/commands/build.ts @@ -69,11 +69,15 @@ export const build = new Command() ) // Write the registry item to the output directory. + // Item names can contain path segments (e.g. "extension/foo"), so + // ensure the nested output directory exists before writing. + const outputPath = path.resolve( + resolvePaths.outputDir, + `${registryItemForBuild.name}.json` + ) + await fs.mkdir(path.dirname(outputPath), { recursive: true }) await fs.writeFile( - path.resolve( - resolvePaths.outputDir, - `${registryItemForBuild.name}.json` - ), + outputPath, JSON.stringify(registryItemForBuild, null, 2) ) } diff --git a/packages/shadcn/src/commands/registry/build.ts b/packages/shadcn/src/commands/registry/build.ts index 942640684d8..aa0887cdb56 100644 --- a/packages/shadcn/src/commands/registry/build.ts +++ b/packages/shadcn/src/commands/registry/build.ts @@ -149,10 +149,14 @@ async function buildRegistry(opts: z.infer) { } // Write the registry item to the output directory. - await fs.writeFile( - path.resolve(resolvePaths.outputDir, `${result.data.name}.json`), - JSON.stringify(result.data, null, 2) + // Item names can contain path segments (e.g. "extension/foo"), so + // ensure the nested output directory exists before writing. + const outputPath = path.resolve( + resolvePaths.outputDir, + `${result.data.name}.json` ) + await fs.mkdir(path.dirname(outputPath), { recursive: true }) + await fs.writeFile(outputPath, JSON.stringify(result.data, null, 2)) } // Copy registry.json to the output directory. From f120cac2da300162777f25d604fbf6c1f36f2174 Mon Sep 17 00:00:00 2001 From: findingsimo <32983782+findingsimo@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:28:01 +0400 Subject: [PATCH 2/4] feat(registry): add @whiskeyjack to the registry directory (#11310) Co-authored-by: shadcn --- apps/v4/registry/directory.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json index ed7ad60036f..8f59dbcd21f 100644 --- a/apps/v4/registry/directory.json +++ b/apps/v4/registry/directory.json @@ -1841,5 +1841,12 @@ "url": "https://ai2.design/r/{name}.json", "description": "Agent-native design system for the shadcn CLI: 51 base components with a full variant, tone and size matrix on an OKLCH token layer, plus 307 styled variations. MIT.", "logo": "" + }, + { + "name": "@whiskeyjack", + "homepage": "https://whiskeyjack.net", + "url": "https://whiskeyjack.net/r/{name}.json", + "description": "A Tauri-first design system: thumb-first components with Metro-style pivot navigation, frosted bottom nav, and tap-again confirmations, on a CSS-variable token pipeline. Hardened across nine shipping apps before extraction.", + "logo": "" } ] From 5ca53ca7c7dea390e0e78091ff7c54adc48c773a Mon Sep 17 00:00:00 2001 From: shadcn Date: Fri, 31 Jul 2026 17:50:10 +0400 Subject: [PATCH 3/4] feat(shadcn): add dynamic search support for registries (#11352) --- .changeset/soft-pandas-search.md | 5 + .../docs/changelog/2026-07-dynamic-search.mdx | 44 +++ .../content/docs/registry/dynamic-search.mdx | 199 ++++++++++++ apps/v4/content/docs/registry/meta.json | 1 + apps/v4/lib/docs.ts | 1 + apps/v4/public/schema/registry.json | 23 ++ packages/shadcn/src/commands/search.ts | 16 +- packages/shadcn/src/registry/api.test.ts | 179 +++++++++++ packages/shadcn/src/registry/api.ts | 62 +++- packages/shadcn/src/registry/schema.test.ts | 50 +++ packages/shadcn/src/registry/schema.ts | 19 +- packages/shadcn/src/registry/search.test.ts | 293 ++++++++++++++++++ packages/shadcn/src/registry/search.ts | 90 +++++- 13 files changed, 958 insertions(+), 24 deletions(-) create mode 100644 .changeset/soft-pandas-search.md create mode 100644 apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx create mode 100644 apps/v4/content/docs/registry/dynamic-search.mdx diff --git a/.changeset/soft-pandas-search.md b/.changeset/soft-pandas-search.md new file mode 100644 index 00000000000..e02f2117e6b --- /dev/null +++ b/.changeset/soft-pandas-search.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +forward search params to registries for server-side dynamic search diff --git a/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx new file mode 100644 index 00000000000..283d705f2e3 --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-07-dynamic-search.mdx @@ -0,0 +1,44 @@ +--- +title: July 2026 - Dynamic Search +description: Registries can now handle search server-side. +date: 2026-07-31 +--- + +**Registries can now handle search server-side.** + +When you run `shadcn search`, the CLI forwards the search parameters to your +registry as query params: + +```txt +GET /r/registry.json?q=button&limit=50&offset=0 +``` + +Return the matching items with a `pagination` object and the CLI uses your +results as-is. This makes search fast for large registries: no more downloading +the full catalog to search it. + +```json title="registry.json?q=button&limit=1" +{ + "name": "acme", + "homepage": "https://acme.com", + "items": [ + { + "name": "button", + "type": "registry:ui", + "description": "A button component." + } + ], + "pagination": { + "total": 12, + "offset": 0, + "limit": 1, + "hasMore": true + } +} +``` + +Dynamic search is opt-in. Static registries ignore the query params and keep +working without any changes. + +See the [Dynamic Search](/docs/registry/dynamic-search) docs for the full +guide. diff --git a/apps/v4/content/docs/registry/dynamic-search.mdx b/apps/v4/content/docs/registry/dynamic-search.mdx new file mode 100644 index 00000000000..8507395e919 --- /dev/null +++ b/apps/v4/content/docs/registry/dynamic-search.mdx @@ -0,0 +1,199 @@ +--- +title: Dynamic Search +description: Implement server-side search for large registries. +--- + +By default, `shadcn search` fetches your entire `registry.json` and filters +items locally. This works well for most registries and requires nothing more +than a static file. + +For large registries with thousands of items, you can implement search on your +registry server instead. The CLI forwards the search parameters to your +registry and your server returns only the matching items. + +Dynamic search is opt-in and fully backwards compatible. Static registries +keep working without any changes. + +## How It Works + +When you run `shadcn search`, the CLI appends the search parameters to the +catalog request: + +```bash +npx shadcn@latest search @acme --query button --limit 50 +``` + +```txt +GET https://acme.com/r/registry.json?q=button&limit=50&offset=0 +``` + +What happens next depends on your registry: + +- **Static registries** ignore the query parameters and return the full + `registry.json`. The CLI filters the items locally. This is the default + behavior and requires no changes. +- **Dynamic registries** filter the items server-side and return the matching + items along with a `pagination` object. When the CLI sees `pagination` in + the response, it trusts the results as pre-filtered and skips local + filtering. + +The presence of `pagination` in the response is what tells the CLI your +registry handles search server-side. There is no configuration or capability +negotiation required. + +## Query Parameters + +The CLI sends the following query parameters with every search request: + +| Parameter | Description | +| --------- | -------------------------------------------------------------- | +| `q` | The search query string. | +| `type` | Comma-separated item types, e.g. `registry:ui,registry:block`. | +| `limit` | Maximum number of items to return. | +| `offset` | Number of items to skip. | + +All parameters are optional. A request without `q` or `type` should return +all items, paginated. + +## Response Format + +Return your regular `registry.json` shape with an additional `pagination` +object: + +```json title="registry.json?q=button&limit=2" +{ + "name": "acme", + "homepage": "https://acme.com", + "items": [ + { + "name": "button", + "type": "registry:ui", + "description": "A button component." + }, + { + "name": "icon-button", + "type": "registry:ui", + "description": "A button component with an icon." + } + ], + "pagination": { + "total": 12, + "offset": 0, + "limit": 2, + "hasMore": true + } +} +``` + +Search results only need `name`, `type` and `description` for each item. You +do not need to include `files`, `dependencies` or other item properties. The +CLI fetches the full item definition when the user runs `shadcn add`. + +### pagination + +| Property | Type | Description | +| --------- | --------- | -------------------------------------------------- | +| `total` | `number` | Total number of items matching the query. | +| `offset` | `number` | Number of items skipped. | +| `limit` | `number` | Maximum number of items in this response. | +| `hasMore` | `boolean` | Whether more items are available beyond this page. | + +## Server Implementation + +Here's an example using a Next.js route handler: + +```typescript title="app/r/registry.json/route.ts" +import { NextRequest, NextResponse } from "next/server" + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + + const query = searchParams.get("q") + const types = searchParams.get("type")?.split(",") + const limit = Number(searchParams.get("limit") ?? 100) + const offset = Number(searchParams.get("offset") ?? 0) + + // Filter items using your database or search index. + const { items, total } = await searchItems({ query, types, limit, offset }) + + return NextResponse.json({ + name: "acme", + homepage: "https://acme.com", + items, + pagination: { + total, + offset, + limit, + hasMore: offset + limit < total, + }, + }) +} +``` + +You can back `searchItems` with anything: a database query, a full-text +search index or an external search service. + +## Multiple Registries + +When searching a single registry, the CLI forwards all parameters and uses +your `pagination` response as-is. + +When searching multiple registries at once, e.g. `shadcn search @acme @lib`, +a global `offset` cannot be split across registries. The CLI forwards `q` and +`type` to each registry along with a `limit` large enough to fill the +requested page (`offset + limit`), then merges and paginates the combined +results locally. Server-side filtering still applies, so each registry only +returns matching items. + +If your registry caps the number of items per response below the requested +`limit`, the CLI treats it as exhausted for deeper pages. Honor the requested +`limit` where possible so all your matches stay reachable through paging. + +## Authentication + +Dynamic search works with all [authentication](/docs/registry/authentication) +patterns. The CLI sends the configured headers and params with the search +request, so you can scope search results to the authenticated user: + +```typescript title="app/r/registry.json/route.ts" +export async function GET(request: NextRequest) { + const token = request.headers.get("authorization")?.replace("Bearer ", "") + const team = await getTeamFromToken(token) + + // Only search items this team can access. + const { items, total } = await searchItems({ + query: request.nextUrl.searchParams.get("q"), + team, + }) + + // ... +} +``` + +## Backwards Compatibility + +- **Static registries** require no changes. Query parameters on a static file + are ignored by the file server and the CLI falls back to local filtering. +- **Older CLI versions** fetch the catalog without query parameters and + ignore the `pagination` field. Your registry should return a sensible + default response for requests without parameters, e.g. the first page of + items. +- **Ranking** is up to your server. When your registry returns pre-filtered + results, the CLI preserves your order instead of re-ranking locally. + +## Testing + +Test your dynamic registry with `curl`: + +```bash +curl "https://acme.com/r/registry.json?q=button&limit=10" +``` + +Then verify with the CLI: + +```bash +npx shadcn@latest search @acme --query button +``` + +To confirm server-side search is active, check that the response includes the +`pagination` object and only the matching items. diff --git a/apps/v4/content/docs/registry/meta.json b/apps/v4/content/docs/registry/meta.json index 61a2cac9653..965ff964244 100644 --- a/apps/v4/content/docs/registry/meta.json +++ b/apps/v4/content/docs/registry/meta.json @@ -8,6 +8,7 @@ "examples", "namespace", "authentication", + "dynamic-search", "mcp", "open-in-v0", "api-reference", diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts index cf9b44393df..64d617b3dbb 100644 --- a/apps/v4/lib/docs.ts +++ b/apps/v4/lib/docs.ts @@ -16,6 +16,7 @@ export const PAGES_NEW = [ "/docs/helpers/ai-sdk", "/docs/helpers/tanstack-ai", "/docs/react/message-scroller", + "/docs/registry/dynamic-search", ] export const PAGES_UPDATED = [] diff --git a/apps/v4/public/schema/registry.json b/apps/v4/public/schema/registry.json index 3201ec6c2f0..7243e0786ec 100644 --- a/apps/v4/public/schema/registry.json +++ b/apps/v4/public/schema/registry.json @@ -27,6 +27,29 @@ "items": { "$ref": "https://ui.shadcn.com/schema/registry-item.json" } + }, + "pagination": { + "type": "object", + "description": "Pagination metadata returned by registries that implement dynamic search. Its presence on a catalog response signals that the items are already filtered and paginated server-side.", + "properties": { + "total": { + "type": "number", + "description": "Total number of items matching the query." + }, + "offset": { + "type": "number", + "description": "Number of items skipped." + }, + "limit": { + "type": "number", + "description": "Maximum number of items in this response." + }, + "hasMore": { + "type": "boolean", + "description": "Whether more items are available beyond this page." + } + }, + "required": ["total", "offset", "limit", "hasMore"] } }, "anyOf": [{ "required": ["items"] }, { "required": ["include"] }] diff --git a/packages/shadcn/src/commands/search.ts b/packages/shadcn/src/commands/search.ts index a9a5e6f58a6..3c540fdaeb9 100644 --- a/packages/shadcn/src/commands/search.ts +++ b/packages/shadcn/src/commands/search.ts @@ -1,5 +1,6 @@ import path from "path" import { configWithDefaults } from "@/src/registry/config" +import { BUILTIN_REGISTRIES } from "@/src/registry/constants" import { clearRegistryContext } from "@/src/registry/context" import { findUnknownSearchTypes, @@ -143,13 +144,20 @@ export const search = new Command() process.exit(1) } - // Only namespace registries passed explicitly need to be discovered and - // added to the config. Registries already configured in components.json - // are resolved directly from the config below. + // Only namespace registries that are not already configured need to be + // discovered and added to the config. Registries in components.json (or + // builtins) are resolved directly from the config below. Skipping + // configured registries also avoids the discovery step downloading the + // full catalog before the search request. const { config: updatedConfig, newRegistries } = await ensureRegistriesInConfig( registries - .filter((registry) => registry.startsWith("@")) + .filter( + (registry) => + registry.startsWith("@") && + !config.registries?.[registry] && + !(registry in BUILTIN_REGISTRIES) + ) .map((registry) => `${registry}/registry`), config, { diff --git a/packages/shadcn/src/registry/api.test.ts b/packages/shadcn/src/registry/api.test.ts index 6d40732525d..345d198182f 100644 --- a/packages/shadcn/src/registry/api.test.ts +++ b/packages/shadcn/src/registry/api.test.ts @@ -791,6 +791,185 @@ describe("getRegistry", () => { expect(receivedHeaders.authorization).toBe("Bearer test-token") }) + it("should forward search params as query params", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + await getRegistry("@acme", { + config: mockConfig, + searchParams: { + query: "button", + types: ["registry:ui", "registry:block"], + limit: 50, + offset: 10, + }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("q")).toBe("button") + expect(url.searchParams.get("type")).toBe("registry:ui,registry:block") + expect(url.searchParams.get("limit")).toBe("50") + expect(url.searchParams.get("offset")).toBe("10") + }) + + it("should not append query params without search params", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + await getRegistry("@acme", { config: mockConfig }) + + expect(receivedUrl).toBe("https://acme.com/registry.json") + }) + + it("should forward search params on direct registry URLs", async () => { + let receivedUrl = "" + server.use( + http.get("https://acme.com/r/registry.json", ({ request }) => { + receivedUrl = request.url + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [], + }) + }) + ) + + await getRegistry("https://acme.com/r/registry.json", { + searchParams: { + query: "button", + limit: 10, + }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("q")).toBe("button") + expect(url.searchParams.get("limit")).toBe("10") + expect(url.searchParams.get("offset")).toBeNull() + expect(url.searchParams.get("type")).toBeNull() + }) + + it("should preserve configured auth params when appending search params", async () => { + let receivedUrl = "" + let receivedAuthHeaders: Record = {} + server.use( + http.get("https://private.com/registry.json", ({ request }) => { + receivedUrl = request.url + request.headers.forEach((value, key) => { + receivedAuthHeaders[key] = value + }) + return HttpResponse.json({ + name: "@private/registry", + homepage: "https://private.com", + items: [], + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@private": { + url: "https://private.com/{name}.json", + params: { + token: "test-token", + }, + headers: { + Authorization: "Bearer test-token", + }, + }, + }, + } as any + + await getRegistry("@private", { + config: mockConfig, + searchParams: { query: "button" }, + }) + + const url = new URL(receivedUrl) + expect(url.searchParams.get("token")).toBe("test-token") + expect(url.searchParams.get("q")).toBe("button") + expect(receivedAuthHeaders.authorization).toBe("Bearer test-token") + }) + + it("should return pagination from dynamic registries", async () => { + server.use( + http.get("https://acme.com/registry.json", () => { + return HttpResponse.json({ + name: "@acme/registry", + homepage: "https://acme.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: 1000, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + }) + ) + + const mockConfig = { + style: "new-york", + tailwind: { baseColor: "neutral", cssVariables: true }, + registries: { + "@acme": { + url: "https://acme.com/{name}.json", + }, + }, + } as any + + const result = await getRegistry("@acme", { + config: mockConfig, + searchParams: { query: "button" }, + }) + + expect(result.pagination).toEqual({ + total: 1000, + offset: 0, + limit: 1, + hasMore: true, + }) + }) + it("should throw RegistryNotConfiguredError when registry is not configured", async () => { const mockConfig = { style: "new-york", diff --git a/packages/shadcn/src/registry/api.ts b/packages/shadcn/src/registry/api.ts index ce6b3874e66..dd9ecc869df 100644 --- a/packages/shadcn/src/registry/api.ts +++ b/packages/shadcn/src/registry/api.ts @@ -56,21 +56,67 @@ type RegistryApiOptions = { useCache?: boolean } -export async function getRegistry(name: string, options?: RegistryApiOptions) { +// Search parameters forwarded to the registry as query params so dynamic +// registries can filter server-side. Static registries ignore them and return +// the full catalog. See https://ui.shadcn.com/docs/registry/dynamic-search. +type RegistrySearchParams = { + query?: string + types?: string[] + limit?: number + offset?: number +} + +type GetRegistryOptions = RegistryApiOptions & { + searchParams?: RegistrySearchParams +} + +function appendSearchParamsToUrl( + url: string, + searchParams?: RegistrySearchParams +) { + if (!searchParams) { + return url + } + + const parsedUrl = new URL(url) + + if (searchParams.query) { + parsedUrl.searchParams.set("q", searchParams.query) + } + + if (searchParams.types?.length) { + parsedUrl.searchParams.set("type", searchParams.types.join(",")) + } + + if (searchParams.limit !== undefined) { + parsedUrl.searchParams.set("limit", String(searchParams.limit)) + } + + if (searchParams.offset !== undefined) { + parsedUrl.searchParams.set("offset", String(searchParams.offset)) + } + + return parsedUrl.toString() +} + +export async function getRegistry(name: string, options?: GetRegistryOptions) { return withRegistryContext(() => getRegistryWithContext(name, options)) } async function getRegistryWithContext( name: string, - options?: RegistryApiOptions + options?: GetRegistryOptions ) { - const { config, useCache } = options || {} + const { config, useCache, searchParams } = options || {} if (isUrl(name)) { - const [result] = await fetchRegistry([name], { useCache }) + const url = appendSearchParamsToUrl(name, searchParams) + const [result] = await fetchRegistry([url], { useCache }) return parseRegistryCatalog(name, result) } + // GitHub registries are raw files. There is no server to run a search, so + // search params are not forwarded and filtering happens locally. const githubSource = resolveGitHubRegistrySource(name) if (githubSource) { return fetchGitHubRegistryCatalog(githubSource, { useCache }) @@ -94,13 +140,17 @@ async function getRegistryWithContext( throw new RegistryNotFoundError(registryName) } + // Append search params before registering headers so the header lookup key + // matches the URL we actually fetch. + const url = appendSearchParamsToUrl(urlAndHeaders.url, searchParams) + if (urlAndHeaders.headers && Object.keys(urlAndHeaders.headers).length > 0) { setRegistryHeaders({ - [urlAndHeaders.url]: urlAndHeaders.headers, + [url]: urlAndHeaders.headers, }) } - const [result] = await fetchRegistry([urlAndHeaders.url], { useCache }) + const [result] = await fetchRegistry([url], { useCache }) return parseRegistryCatalog(registryName, result) } diff --git a/packages/shadcn/src/registry/schema.test.ts b/packages/shadcn/src/registry/schema.test.ts index 808b5d8ae9f..8115dc9676c 100644 --- a/packages/shadcn/src/registry/schema.test.ts +++ b/packages/shadcn/src/registry/schema.test.ts @@ -80,4 +80,54 @@ describe("registrySchema", () => { expect(result.success).toBe(false) }) + + it("should accept registries with pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: 100, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toEqual({ + total: 100, + offset: 0, + limit: 1, + hasMore: true, + }) + } + }) + + it("should accept registries without pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toBeUndefined() + } + }) + + it("should reject registries with invalid pagination", () => { + const result = registrySchema.safeParse({ + name: "example", + homepage: "https://example.com", + items: [{ name: "button", type: "registry:ui" }], + pagination: { + total: "100", + }, + }) + + expect(result.success).toBe(false) + }) }) diff --git a/packages/shadcn/src/registry/schema.ts b/packages/shadcn/src/registry/schema.ts index 7948ee35be7..c9b144e277b 100644 --- a/packages/shadcn/src/registry/schema.ts +++ b/packages/shadcn/src/registry/schema.ts @@ -198,6 +198,16 @@ export type RegistryBaseItem = Extract // Helper type for registry:font items specifically. export type RegistryFontItem = Extract +// Pagination metadata returned by registries that implement dynamic search. +// Its presence on a catalog response signals that the items are already +// filtered and paginated server-side. +export const registryPaginationSchema = z.object({ + total: z.number(), + offset: z.number(), + limit: z.number(), + hasMore: z.boolean(), +}) + const registryBaseSchema = z .object({ $schema: z.string().optional(), @@ -205,6 +215,7 @@ const registryBaseSchema = z homepage: z.string().optional(), include: z.array(z.string()).optional(), items: z.array(registryItemSchema).optional(), + pagination: registryPaginationSchema.optional(), }) .refine( (registry) => @@ -227,6 +238,7 @@ export const registrySchema = registryChunkSchema.pipe( homepage: z.string(), include: z.array(z.string()).optional(), items: z.array(registryItemSchema), + pagination: registryPaginationSchema.optional(), }) ) @@ -293,12 +305,7 @@ export const searchResultErrorSchema = z.object({ }) export const searchResultsSchema = z.object({ - pagination: z.object({ - total: z.number(), - offset: z.number(), - limit: z.number(), - hasMore: z.boolean(), - }), + pagination: registryPaginationSchema, items: z.array(searchResultItemSchema), // Registries that failed to load during the search. Only present when a // search tolerates per-registry failures (see searchRegistries' diff --git a/packages/shadcn/src/registry/search.test.ts b/packages/shadcn/src/registry/search.test.ts index ff11f1545dd..b4a0aff7d57 100644 --- a/packages/shadcn/src/registry/search.test.ts +++ b/packages/shadcn/src/registry/search.test.ts @@ -1087,3 +1087,296 @@ describe("findUnknownSearchTypes", () => { expect(findUnknownSearchTypes(["internal"])).toEqual(["internal"]) }) }) + +describe("searchRegistries with dynamic registries", () => { + it("forwards search params to the registry", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [], + }) + + await searchRegistries(["@acme"], { + query: "button", + types: ["ui", "registry:block"], + limit: 20, + offset: 40, + }) + + expect(mockGetRegistry).toHaveBeenCalledWith( + "@acme", + expect.objectContaining({ + searchParams: { + query: "button", + types: ["registry:ui", "registry:block"], + limit: 20, + offset: 40, + }, + }) + ) + + mockGetRegistry.mockRestore() + }) + + it("pushes down filters but not offset when searching multiple registries", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [], + }) + + await searchRegistries(["@one", "@two"], { + query: "button", + limit: 10, + offset: 20, + }) + + // Each registry is over-fetched (offset + limit) so the requested page + // can be filled after the merge. + for (const registry of ["@one", "@two"]) { + expect(mockGetRegistry).toHaveBeenCalledWith( + registry, + expect.objectContaining({ + searchParams: { + query: "button", + types: undefined, + limit: 30, + offset: undefined, + }, + }) + ) + } + + mockGetRegistry.mockRestore() + }) + + it("trusts server results when a single dynamic registry is searched", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + // The server returns items that would not match a local fuzzy search to + // prove no local filtering is applied on top. + mockGetRegistry.mockResolvedValue({ + name: "acme", + homepage: "https://acme.com", + items: [ + { + name: "unrelated-item", + type: "registry:ui", + description: "Does not mention the query.", + }, + ], + pagination: { + total: 500, + offset: 10, + limit: 1, + hasMore: true, + }, + }) + + const results = await searchRegistries(["@acme"], { + query: "button", + limit: 1, + offset: 10, + }) + + expect(results).toEqual({ + items: [ + { + name: "unrelated-item", + type: "registry:ui", + description: "Does not mention the query.", + registry: "@acme", + addCommandArgument: "@acme/unrelated-item", + }, + ], + pagination: { + total: 500, + offset: 10, + limit: 1, + hasMore: true, + }, + }) + + mockGetRegistry.mockRestore() + }) + + it("merges dynamic and static registries", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@dynamic") { + return { + name: "dynamic", + homepage: "https://dynamic.com", + items: [ + { + name: "button", + type: "registry:ui", + description: "A server-filtered button.", + }, + ], + pagination: { + total: 42, + offset: 0, + limit: 1, + hasMore: true, + }, + } + } + if (name === "@static") { + return { + name: "static", + homepage: "https://static.com", + items: [ + { + name: "button-group", + type: "registry:ui", + description: "A button group.", + }, + { + name: "card", + type: "registry:ui", + description: "A card component.", + }, + ], + } + } + throw new Error(`Unknown registry: ${name}`) + }) + + const results = await searchRegistries(["@dynamic", "@static"], { + query: "button", + }) + + // Server-filtered items are kept as-is. Static items still go through + // the local fuzzy filter, which drops "card". + expect(results.items).toEqual([ + { + name: "button", + type: "registry:ui", + description: "A server-filtered button.", + registry: "@dynamic", + addCommandArgument: "@dynamic/button", + }, + { + name: "button-group", + type: "registry:ui", + description: "A button group.", + registry: "@static", + addCommandArgument: "@static/button-group", + }, + ]) + + // The dynamic registry's total includes matches beyond the returned + // items. No limit was requested, so the identical request would be sent + // again for a deeper page — the tail is unreachable and hasMore is false. + expect(results.pagination.total).toBe(43) + expect(results.pagination.hasMore).toBe(false) + + mockGetRegistry.mockRestore() + }) + + it("reports more pages when a dynamic registry fills the requested limit", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockImplementation(async (name: string) => { + if (name === "@dynamic") { + return { + name: "dynamic", + homepage: "https://dynamic.com", + items: [ + { name: "button", type: "registry:ui" }, + { name: "button-group", type: "registry:ui" }, + ], + pagination: { + total: 42, + offset: 0, + limit: 2, + hasMore: true, + }, + } + } + return { + name: "static", + homepage: "https://static.com", + items: [], + } + }) + + const results = await searchRegistries(["@dynamic", "@static"], { + query: "button", + limit: 2, + }) + + // The registry filled the requested limit, so deeper pages re-request it + // with a larger limit and can surface the remaining matches. + expect(results.items).toHaveLength(2) + expect(results.pagination.total).toBe(42) + expect(results.pagination.hasMore).toBe(true) + + mockGetRegistry.mockRestore() + }) + + it("does not report more pages when a dynamic registry caps its response", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + // The registry claims 500 matches but caps every response at one item, + // regardless of the requested limit. + mockGetRegistry.mockResolvedValue({ + name: "capped", + homepage: "https://capped.com", + items: [{ name: "item-0", type: "registry:ui" }], + pagination: { + total: 500, + offset: 0, + limit: 1, + hasMore: true, + }, + }) + + const results = await searchRegistries(["@capped", "@static"], { + limit: 5, + offset: 10, + }) + + // The page beyond the cap is empty. hasMore must be false so paging + // stops instead of looping through empty pages toward total. + expect(results.items).toEqual([]) + expect(results.pagination.hasMore).toBe(false) + + mockGetRegistry.mockRestore() + }) + + it("keeps local pagination when no registry returns pagination", async () => { + const mockGetRegistry = vi.mocked(getRegistry) + + mockGetRegistry.mockResolvedValue({ + name: "static", + homepage: "https://static.com", + items: [ + { name: "button", type: "registry:ui" }, + { name: "card", type: "registry:ui" }, + { name: "input", type: "registry:ui" }, + ], + }) + + const results = await searchRegistries(["@static"], { + limit: 2, + offset: 1, + }) + + expect(results.items.map((item) => item.name)).toEqual(["card", "input"]) + expect(results.pagination).toEqual({ + total: 3, + offset: 1, + limit: 2, + hasMore: false, + }) + + mockGetRegistry.mockRestore() + }) +}) diff --git a/packages/shadcn/src/registry/search.ts b/packages/shadcn/src/registry/search.ts index 2f0665ecb7a..11f8dc07352 100644 --- a/packages/shadcn/src/registry/search.ts +++ b/packages/shadcn/src/registry/search.ts @@ -1,5 +1,6 @@ import { registryItemTypeSchema, + registryPaginationSchema, searchResultErrorSchema, searchResultItemSchema, searchResultsSchema, @@ -103,18 +104,42 @@ async function searchRegistriesWithContext( continueOnError, } = options || {} - let allItems: z.infer[] = [] const errors: z.infer[] = [] + // Search params are forwarded to every registry so dynamic registries can + // filter server-side. Pagination only composes when a single registry is + // searched. Across multiple registries a global offset cannot be + // distributed, so we push down filters only and over-fetch enough items + // (offset + limit) from each registry to fill the requested page locally. + const isSingleRegistry = registries.length === 1 + const wireTypes = types?.map((type) => toRegistryItemType(type)) + const searchParams = isSingleRegistry + ? { query, types: wireTypes, limit, offset } + : { + query, + types: wireTypes, + limit: limit !== undefined ? (offset || 0) + limit : undefined, + offset: undefined, + } + // Fetch registries concurrently (capped), then process the results in the // original order so the output is deterministic regardless of which // responses land first. This matters most when searching many registries. const outcomes = await mapSettledWithConcurrency( registries, SEARCH_CONCURRENCY, - (registry) => getRegistry(registry, { config, useCache }) + (registry) => getRegistry(registry, { config, useCache, searchParams }) ) + // A registry that returns `pagination` has already filtered and paginated + // its items server-side (see the dynamic search docs). Its items are kept + // as-is while items from static registries go through the local pipeline. + let localItems: z.infer[] = [] + const serverResults: { + items: z.infer[] + pagination: z.infer + }[] = [] + for (let index = 0; index < registries.length; index++) { const registry = registries[index] const outcome = outcomes[index] @@ -144,7 +169,25 @@ async function searchRegistriesWithContext( ), })) - allItems = allItems.concat(itemsWithRegistry) + if (outcome.value.pagination) { + serverResults.push({ + items: itemsWithRegistry, + pagination: outcome.value.pagination, + }) + continue + } + + localItems = localItems.concat(itemsWithRegistry) + } + + // Single dynamic registry: the server did all the work. Return its items + // and pagination verbatim so ranking and totals are the server's. + if (isSingleRegistry && serverResults.length === 1) { + const [serverResult] = serverResults + return searchResultsSchema.parse({ + pagination: serverResult.pagination, + items: serverResult.items, + }) } // Filter by type before the fuzzy query. Accepts both shorthand ("ui") and @@ -153,7 +196,7 @@ async function searchRegistriesWithContext( const wantedTypes = new Set( types.map((type) => formatSearchResultType(type).toLowerCase()) ) - allItems = allItems.filter( + localItems = localItems.filter( (item) => item.type && wantedTypes.has(formatSearchResultType(item.type).toLowerCase()) @@ -161,23 +204,48 @@ async function searchRegistriesWithContext( } if (query) { - allItems = searchItems(allItems, { + localItems = searchItems(localItems, { query, - limit: allItems.length, + limit: localItems.length, keys: ["name", "description"], }) as z.infer[] } + // Merge pre-filtered items (in registry order) with locally filtered items, + // then paginate the combined list. `total` includes matches a dynamic + // registry counted but did not return, so the match count stays accurate + // even when a registry truncated its response. + const allItems = serverResults + .flatMap((serverResult) => serverResult.items) + .concat(localItems) + const serverTotal = serverResults.reduce( + (sum, serverResult) => sum + serverResult.pagination.total, + 0 + ) + const paginationOffset = offset || 0 const paginationLimit = limit || allItems.length - const totalItems = allItems.length + const totalItems = serverTotal + localItems.length + + // Deeper pages re-request every registry with a larger over-fetch limit, so + // a dynamic registry can serve them only if it filled the current request. + // A registry that returned fewer items than requested is capped and its + // remaining matches are unreachable through paging — it must not drive + // `hasMore`, or paging would loop through empty pages forever. + const serverHasMore = serverResults.some( + (serverResult) => + serverResult.pagination.hasMore && + searchParams.limit !== undefined && + serverResult.items.length >= searchParams.limit + ) const result: z.infer = { pagination: { total: totalItems, offset: paginationOffset, limit: paginationLimit, - hasMore: paginationOffset + paginationLimit < totalItems, + hasMore: + paginationOffset + paginationLimit < allItems.length || serverHasMore, }, items: allItems.slice(paginationOffset, paginationOffset + paginationLimit), // Only surface errors when present so consumers parsing successful @@ -316,6 +384,12 @@ export function formatSearchResultType(type?: string) { return type.startsWith("registry:") ? type.slice("registry:".length) : type } +// Inverse of formatSearchResultType. Normalizes a type filter to the full +// namespaced form for the wire, e.g. "ui" -> "registry:ui". +export function toRegistryItemType(type: string) { + return type.startsWith("registry:") ? type : `registry:${type}` +} + // Internal-only types that should not be offered as a --type filter. const INTERNAL_TYPES = ["registry:example", "registry:internal"] From cb2bcd88d93b2f9bddb030e9136f1f8773e7eac4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:57:34 +0400 Subject: [PATCH 4/4] chore(release): version packages (#11350) Co-authored-by: shadcn --- .changeset/build-nested-output-dirs.md | 5 ----- .changeset/soft-pandas-search.md | 5 ----- apps/v4/package.json | 2 +- packages/shadcn/CHANGELOG.md | 8 ++++++++ packages/shadcn/package.json | 2 +- pnpm-lock.yaml | 2 +- 6 files changed, 11 insertions(+), 13 deletions(-) delete mode 100644 .changeset/build-nested-output-dirs.md delete mode 100644 .changeset/soft-pandas-search.md diff --git a/.changeset/build-nested-output-dirs.md b/.changeset/build-nested-output-dirs.md deleted file mode 100644 index e9a0f2212c6..00000000000 --- a/.changeset/build-nested-output-dirs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"shadcn": patch ---- - -fix `shadcn build` failing with ENOENT when registry item names contain path segments (e.g. `extension/foo`) by creating nested output directories before writing diff --git a/.changeset/soft-pandas-search.md b/.changeset/soft-pandas-search.md deleted file mode 100644 index e02f2117e6b..00000000000 --- a/.changeset/soft-pandas-search.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"shadcn": patch ---- - -forward search params to registries for server-side dynamic search diff --git a/apps/v4/package.json b/apps/v4/package.json index e6c172b9ba2..48ed248e9e7 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.16.0", + "shadcn": "4.16.1", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index ceebf8c9c88..090cc314b72 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,13 @@ # shadcn +## 4.16.1 + +### Patch Changes + +- [#11322](https://github.com/shadcn-ui/ui/pull/11322) [`bfa1b5e9a69a155b2f590523d50fda810bde1a9a`](https://github.com/shadcn-ui/ui/commit/bfa1b5e9a69a155b2f590523d50fda810bde1a9a) Thanks [@AndrewBarba](https://github.com/AndrewBarba)! - fix `shadcn build` failing with ENOENT when registry item names contain path segments (e.g. `extension/foo`) by creating nested output directories before writing + +- [#11352](https://github.com/shadcn-ui/ui/pull/11352) [`5ca53ca7c7dea390e0e78091ff7c54adc48c773a`](https://github.com/shadcn-ui/ui/commit/5ca53ca7c7dea390e0e78091ff7c54adc48c773a) Thanks [@shadcn](https://github.com/shadcn)! - forward search params to registries for server-side dynamic search + ## 4.16.0 ### Minor Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index bbf19f1d49b..48d3792a8f7 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.16.0", + "version": "4.16.1", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2fefa5b34a..db0bb755de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.16.0 + specifier: 4.16.1 version: link:../../packages/shadcn shiki: specifier: ^3.23.0