Skip to content

fix(scripts): shadcn-sync rewrites the registry paths Shadcn actually serves, and refuses to write a file when it cannot - #3033

Merged
os-zhuang merged 1 commit into
mainfrom
claude/shadcn-sync-path-rewrite
Jul 30, 2026
Merged

fix(scripts): shadcn-sync rewrites the registry paths Shadcn actually serves, and refuses to write a file when it cannot#3033
os-zhuang merged 1 commit into
mainfrom
claude/shadcn-sync-path-rewrite

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Root-causes the landmine #3029 defused for one component. pnpm shadcn:update-all currently reports ✓ Updated while writing components that cannot compile.

What was wrong

updateComponent rewrote exactly two alias shapes — @/lib/utils and @/components/ui/x. I surveyed all 46 registry entries this manifest points at. The second shape never appears. Shadcn reorganised its layout, and what the endpoints serve today is:

shape components rewritten before?
@/lib/utils 42
@/registry/default/ui/<name> 8
@/registry/default/hooks/<name> 2
@/registry/new-york/ui/<name> 1
@/registry/default/lib/<name> 1

So syncing calendar, carousel, command, form, pagination, sidebar, toggle-group or chart left @/registry/… specifiers behind. Those don't resolve from src/ui/, so the script replaced working code with code that cannot compile — and reported success. That is the mechanism behind resizable being hand-patched and eventually reclassified in #3029.

The fix

1. The table covers what's actually served. ui/ → sibling (./x), hooks/../hooks/x, lib/../lib/x, across any <style> segment. The older @/components/ui/x and @/hooks/x forms stay in so a re-pointed source keeps working. Matching is on the quoted specifier rather than from "…", so export … from, dynamic import() and vi.mock() are covered.

2. It fails closed — the part that actually matters. Any @/… surviving the rewrite is now a hard refusal to write, naming the specifier and pointing at the table:

✗ Refusing to write sidebar.tsx — unmapped registry import path(s):
    @/registry/tokyo/blocks/button
  Shadcn's alias layout changed. Add a rule to IMPORT_REWRITES in this script.

An enumerated list of paths goes stale the next time Shadcn reorganises — that's precisely how this bug arose. The guard converts that from a silent build break into a clear message.

3. --check / --diff rewrite before comparing, so import-path style alone no longer reads as drift, and --diff warns when --update would refuse.

4. Multi-file entries are no longer silently truncated. The script only writes files[0]; toast ships 3. It now names the files it isn't writing.

Verification

  • All 46 live registry entries rewrite to zero unmapped aliases, and every rewritten relative specifier resolves on disk (checked with the script's own exported helpers, not a reimplementation)
  • Negative control: a hypothetical future layout @/registry/tokyo/blocks/button is flagged, while known aliases still rewrite
  • End-to-end --update sidebar — the entry exercising all three shapes — now reproduces the import block already on main exactly, zero difference. The remaining 21/−24 line delta is genuine upstream drift in the component body, which is what a sync is for. Restored afterwards: no component file changes in this PR
  • --update resizable still refuses, so fix(components): resizable is a diverged file, not a synced one — stop the sync from breaking the build, and finish the v4 migration in it #3029's defusal is intact
  • node --check + eslint clean

Still open (not this PR)

Re-syncing wholesale still discards local named-import additions — command has added DialogTitle/DialogDescription, sonner has added toast. Those break loudly rather than silently, but they mean --update-all remains unsafe to run unattended. Worth a follow-up.

No changeset — tooling only, no package output.

🤖 Generated with Claude Code

… serves, and refuses to write a file when it cannot

`updateComponent` rewrote two alias shapes: `@/lib/utils` and
`@/components/ui/x`. Surveying all 46 registry entries this manifest
points at, the second one **never appears**. Shadcn moved to a different
layout, and what it serves today is:

    42 components   @/lib/utils                      ← rewritten
     8 components   @/registry/default/ui/<name>     ← missed
     2 components   @/registry/default/hooks/<name>  ← missed
     1 component    @/registry/new-york/ui/<name>    ← missed
     1 component    @/registry/default/lib/<name>    ← missed

So syncing `calendar`, `carousel`, `command`, `form`, `pagination`,
`sidebar`, `toggle-group` or `chart` left `@/registry/...` specifiers
that do not resolve from `src/ui/` — the script swapped working code for
code that cannot compile, and said `✓ Updated`. That is how `resizable`
ended up hand-patched and eventually reclassified (#3029).

Two changes:

**The rewrite table now covers what is served.** `ui/` maps to a sibling
(`./x`), `hooks/` and `lib/` step up a level (`../hooks/x`, `../lib/x`),
across any `<style>` segment. The older `@/components/ui/x` and `@/hooks/x`
forms stay in the table so a re-pointed source keeps working. Matching is
on the quoted specifier rather than on `from "…"`, so `export … from`,
dynamic `import()` and `vi.mock()` are covered.

**It fails closed.** Any `@/…` left after rewriting is now a hard refusal
to write, naming the specifier and pointing at the table. An enumerated
list of paths silently goes stale the next time Shadcn reorganises; the
guard is what makes that visible instead of catastrophic. Verified with a
hypothetical future layout (`@/registry/tokyo/blocks/button`): flagged,
while known aliases still rewrite.

Also: `--check` and `--diff` now rewrite before comparing, so import-path
style alone no longer reads as drift, and `--diff` warns when `--update`
would refuse. A registry entry shipping more than one file (`toast` ships
3) now says which files it is not writing rather than silently dropping
them.

Verified against all 46 live registry entries: every one rewrites to zero
unmapped aliases, and every rewritten relative specifier resolves on disk.
End-to-end `--update sidebar` — the entry exercising all three shapes —
now reproduces the import block already on main **exactly**, zero
difference; the remaining 21/-24 line delta is genuine upstream drift in
the component body. Restored afterwards; no component file changes here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Jul 30, 2026 12:37pm

Request Review

@os-zhuang
os-zhuang merged commit 4c6fa5b into main Jul 30, 2026
15 checks passed
@os-zhuang
os-zhuang deleted the claude/shadcn-sync-path-rewrite branch July 30, 2026 12:42
os-zhuang added a commit that referenced this pull request Jul 30, 2026
… compiles the package after it writes (#3035)

#3033 stopped the sync writing unresolvable import paths. It did not make
`--update-all` safe to run: the other failure mode is overwriting local
edits, and I was wrong about how that shows up. I expected a loud type
error. It is silent.

`command.tsx` is the worked example. It carries local additions upstream
has never had — a `sr-only` `DialogTitle`/`DialogDescription` on
`CommandDialog` (Radix requires an accessible name) and `contentProps`,
which is the ADR-0054 C4 testability contract letting callers attach a
stable locator to the dialog element. Syncing it deletes 38 lines,
removes both, and **type-checks clean**, because upstream drops each
addition together with its usages. Nothing downstream notices.

So two layers, not one:

**Refuse before writing.** Any line the local file has that the incoming
version does not is reported, and the write is skipped. Comments count —
a comment explaining why we diverged is exactly what must not evaporate.
`--force` overrides. Measured across all 46 registry entries this
discriminates rather than crying wolf: 29 have no local-only lines and
sync cleanly, 17 are protected. `--update-all` now reports skipped
separately from failed.

**Compile after writing.** `--update` and `--update-all` then run
`pnpm --filter @object-ui/components type-check` and say plainly whether
the package still builds, with the rollback command on failure.
`--no-verify` skips it. This is the backstop for whatever the first layer
does not model — it would not have caught `command`, which is precisely
why the first layer exists.

Reporting only; neither layer rolls back for you. Auto-reverting would
throw away a sync someone may want to fix by hand.

Verified end to end: `--update command` refuses and lists the ADR-0054
lines; `--force` overrides it; `--update button` (0 local-only lines)
syncs and leaves the tree byte-identical; the #3033 unmapped-path guard
and the `resizable` reclassification both still hold. Tree restored — no
component file changes here.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…46 components "modified" (#3049)

The heuristic was `lineDiff > 10 || localContent.includes('ObjectUI') ||
localContent.includes('data-slot')`. Every synced file carries the
ObjectUI header this script prepends, so the second clause was true for
all 46 of them: `--check` reported the entire tracked set as "modified",
every time, which is the same as reporting nothing.

#3035 added `localOnlyLines` for the pre-write guard. It is directional —
lines in the first argument the second lacks — so calling it both ways
answers the two questions that actually decide whether to sync: what
would a sync DESTROY, and what would it GAIN. That replaces the
heuristic, and splits the old "modified" bucket in two:

  ↑ outdated  upstream moved, no local edits      → safe to sync
  ⚠ modified  local edits present                 → --update refuses

Each line now carries counts, and the summary names the components in
each bucket so the next step is obvious without running --diff 46 times.

What it says about this repo right now:

  ✓ Identical: 29    ↑ Outdated: 0    ⚠ Modified: 17    ● Custom: 2

Zero outdated is the headline. Every component where upstream has moved
also carries local edits, so there is currently no component
`--update-all` could touch without deleting something — it has no safe
work to do at all. That was invisible before.

The per-component counts line up with the guard that enforces them
(`command` 35, `select` 32, `sidebar` 24, `table` 17 …), because both
sides call the same function.

`--check` stays exit 0: it is a status report, and 17 locally-modified
components is the steady state here, not a failure.

Verified against the live registry. The `outdated` bucket is the one real
traffic never exercises, so it was tested by deleting a line from an
identical component (`separator`): it flips to `↑ … 1 upstream line(s) to
pick up — safe to sync` and the summary offers the exact --update
command. Restored after. `--list`, `--diff`, `--update` and both #3033 /
#3035 guards regression-checked; no component file changes.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant