Skip to content

Commit 723bce4

Browse files
authored
feat(mantle-vite-plugins): add typed allowlist option to mantleTwSourcePlugin (#1044)
* feat(mantle-vite-plugins): add typed allowlist option to mantleTwSourcePlugin Adds `allowlist?: (MantleComponentName | (string & {}))[]` to `MantleTwSourcePluginOptions` so consumers can pin specific mantle components into the `@source` block unconditionally — useful for components that the directory scanner misses due to transitive imports within mantle itself (e.g. Command importing Dialog internally) or imports in workspace packages outside the `include` paths. - `MantleComponentName` is a codegen'd string-literal union of all component directory names from `packages/mantle/src/components/`, giving IDE autocomplete while still accepting arbitrary strings - Codegen script at `scripts/generate-component-names.ts` runs via `tsx` as part of `build` and `typecheck` - `slugify`/`slugifyComponentName` extracted to `src/slugify.ts` with tests, ported from the frontend repo - `tsdown` moved to the pnpm catalog; both `@ngrok/mantle` and `@ngrok/mantle-vite-plugins` now reference `catalog:` - Decision doc updated with the transitive-import correctness bug and the allowlist escape hatch * fix @tsdown/css breakage * ope * address pr comments * update doc * improvements! * address pr comments again * address even more pr comments * fix packagessss * more comments! * oop * shared regex * /simplify * address PR feedback, fix tests and shizzz * fix slop * filter tests * pr feedback * fix more slop
1 parent d52971c commit 723bce4

24 files changed

+1362
-236
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@ngrok/mantle-vite-plugins": patch
3+
---
4+
5+
Fix `mantleTwSourcePlugin` correctness for code-split mantle builds:
6+
7+
- `@source` directives now emit two patterns per component — an exact entry stub (`button.js`) and a hashed-chunk glob (`button-*.js`) — so Tailwind scans both the named re-export file and the code-split chunk that actually contains class strings. A single `button*.js` glob was intentionally avoided because it would also match prefix-sharing names like `button` matching `button-group`.
8+
- Added `resolveId` hook for module-graph-aware component tracking: catches mantle imports from workspace packages outside `include` and transitive mantle-internal imports that the directory scan missed.
9+
- Added `closeBundle` hook that writes the precise set (directory scan ∪ `resolveId` intercepts ∪ allowlist) after a production build. SSR builds are skipped so only the client build's complete component set is written — the server build resolves fewer components and must not overwrite it.
10+
- Added `allowlist` option to explicitly include components regardless of scanner detection. Accepts PascalCase (`"CommandDialog"`) or kebab-case (`"command-dialog"`).

apps/www/app/docs/utils/sorting.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Compares two `Date` values for descending (newest-first) sort order. Pass direct
162162
import { compareDatesNewestToOldest } from "@ngrok/mantle/utils";
163163

164164
const dates = [new Date("2023-01-01"), new Date("2024-06-15"), new Date("2022-12-31")];
165-
dates.sort(compareDatesNewestToOldest);
165+
dates.toSorted(compareDatesNewestToOldest);
166166
// → [2024-06-15, 2023-01-01, 2022-12-31]
167167
```
168168

@@ -174,6 +174,6 @@ Compares two `Date` values for ascending (oldest-first) sort order.
174174
import { compareDatesOldestToNewest } from "@ngrok/mantle/utils";
175175

176176
const dates = [new Date("2023-01-01"), new Date("2024-06-15"), new Date("2022-12-31")];
177-
dates.sort(compareDatesOldestToNewest);
177+
dates.toSorted(compareDatesOldestToNewest);
178178
// → [2022-12-31, 2023-01-01, 2024-06-15]
179179
```
Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,114 @@
1-
# mantleTwSourcePlugin: Module Graph Approach (Deferred)
1+
# mantleTwSourcePlugin: Research Log
22

33
## Background
44

55
`mantleTwSourcePlugin` injects Tailwind CSS `@source` directives into the app's global CSS file for only the `@ngrok/mantle` components that are actually used, so Tailwind doesn't scan the entire mantle `dist/` directory.
66

7-
The current (shipped) implementation uses a **directory scan**: it walks the directories in `include` looking for `@ngrok/mantle/<name>` import strings in source files. This works for apps where all mantle imports live directly in the scanned directories.
7+
The current (shipped) implementation uses a **directory scan**: it walks the directories in `include` looking for `@ngrok/mantle/<name>` import strings in source files.
88

9-
## The Problem
9+
---
10+
11+
## Problem 1 — Monorepo workspace packages (original motivation)
1012

1113
Directory scanning breaks down in monorepos where the app depends on workspace packages that themselves import mantle. Example: `apps/www` depends on `packages/ui`, and `packages/ui/src/severity.ts` imports `@ngrok/mantle/badge`. If you only scan `apps/www/app`, you miss `badge`. If you add `packages/ui/src` to `include`, you pull in _every_ mantle component imported anywhere in that package — not just the ones reachable from `apps/www`.
1214

13-
## Proposed Solution: `resolveId` Two-Pass
15+
**Proposed fix (deferred):** Use Vite's `resolveId` hook to intercept every `@ngrok/mantle/<name>` import actually resolved during the session. Module-graph-aware: only sees imports reachable from the app's entry points, including through workspace packages, without overcounting.
16+
17+
### `resolveId` two-pass design
18+
19+
1. **Startup (`configResolved`)** — Read the existing `@source` block from the CSS file (`parseComponentsFromCssFile`) to seed `knownComponents`. On first cold start, fall back to a directory scan of `include` to bootstrap.
20+
2. **Session (`resolveId`)** — Intercept every `@ngrok/mantle/<name>` specifier. Accumulate into `seenComponents`.
21+
- Dev: debounce-write the CSS with `knownComponents ∪ seenComponents` when a new component is first seen.
22+
- Prod: `closeBundle` writes `seenComponents` (precise set for this build), removing stale entries.
23+
24+
### `resolveId` known gaps / risks (never shipped)
25+
26+
1. **SSR double-build**: React Router runs a client build then a server build. `closeBundle` fires twice. The second call (server) may have a smaller `seenComponents` than the client, overwriting the CSS with fewer components. Client-only components like modals and tooltips would be dropped.
27+
2. **First cold start in prod CI**: No existing `@source` block → bootstrap via directory scan. If `resolveId` then fires zero times, the guard silently no-ops, leaving stale bootstrapped state.
28+
3. **`resolveId` timing in dev**: Fires lazily as routes are visited. Fresh clone + dev start → missing components until those routes are visited.
29+
4. **No integration test**: All tests are unit tests. Plugin hooks are unverified against a real Vite instance.
30+
31+
**Status:** `parseComponentsFromCssFile` was implemented in `internals.ts` and is tested. The plugin itself was reverted to the directory-scan implementation pending these gaps.
32+
33+
---
34+
35+
## Problem 2 — Transitive imports within mantle itself (discovered 2026-03-09)
36+
37+
Mantle components import each other directly. For example, `command.tsx` imports from the `dialog` component. An app that imports `@ngrok/mantle/command` but never directly imports `@ngrok/mantle/dialog` will only get an `@source` directive for `command` — dialog styles will be missing.
38+
39+
The directory scan only finds imports the _app_ makes directly. It does not follow the mantle-internal dependency graph. This is a correctness bug, not a performance gap.
40+
41+
**Attempted escape hatch:** Added an `allowlist` option to `MantleTwSourcePluginOptions` so consumers can explicitly name components the scanner misses. Accepts PascalCase (`"Dialog"`) or kebab-case (`"dialog"`). Merged with scanned results before writing `@source` block.
1442

15-
Use Vite's `resolveId` hook to intercept every `@ngrok/mantle/<name>` import that Vite actually resolves during the session. This is module-graph-aware: it only sees imports reachable from the app's entry points, including transitive imports through workspace packages, without overcounting.
43+
**This turned out not to work** — see Problem 3.
1644

17-
### Design
45+
---
1846

19-
**Two-pass model:**
47+
## Problem 3 — Entry-point stubs contain no classes (discovered 2026-03-09)
2048

21-
1. **Startup (`configResolved`)** — Read the existing `@source` block from the CSS file (`parseComponentsFromCssFile`) to seed `knownComponents`. Tailwind gets a complete snapshot from the previous run immediately. On first cold start (no existing block), fall back to a directory scan of `include` to bootstrap.
49+
**This is the root cause that makes the entire plugin ineffective.**
2250

23-
2. **Session (`resolveId`)** — Intercept every `@ngrok/mantle/<name>` import specifier. Accumulate into `seenComponents`.
24-
- Dev: when a new component is first seen, debounce-write the CSS with `knownComponents ∪ seenComponents`
25-
- Prod: `closeBundle` writes `seenComponents` (precise set for this build), removing stale entries
51+
When tsdown builds mantle with multiple entry points, rolldown uses code splitting: shared code is extracted into hashed chunk files (e.g. `dialog-BswTx6oS.js`) and the named entry files become thin re-export stubs:
2652

27-
**New helper:** `parseComponentsFromCssFile(cssFile)` in `internals.ts` — reads the existing `@source` block and extracts component names. This is the inverse of `writeSourcesToCssFile` and is already implemented and tested.
53+
```js
54+
// dist/dialog.js — the file @source points at
55+
export { t as Dialog } from "./dialog-BswTx6oS.js";
56+
```
2857

29-
### What Was Implemented
58+
All Tailwind class strings live in the chunk files. Tailwind's `@source` scanner reads file contents with regex — it does not execute JavaScript or follow re-exports. So it scans `dist/dialog.js`, finds no class strings, and moves on. No styles are discovered.
3059

31-
- `parseComponentsFromCssFile` added to `internals.ts` with 5 tests (round-trip, missing file, no block, block removed)
32-
- Plugin rewritten with `config`/`configResolved`/`resolveId`/`closeBundle` hooks
33-
- `configureServer` file watcher removed (replaced by `resolveId`)
34-
- README and JSDoc updated
60+
This means:
3561

36-
### Known Gaps / Risks
62+
- `@source "dist/dialog.js"` → finds nothing.
63+
- Adding `"Dialog"` to the `allowlist` → emits `@source "dist/dialog.js"` → finds nothing.
64+
- The per-component `@source` optimization is a no-op for any app using the current build output.
3765

38-
1. **SSR double-build** (highest risk): React Router runs a client build then a server build. `closeBundle` fires twice. `seenComponents` is a shared closure variable — both builds accumulate into it. The second `closeBundle` call overwrites the first. If the _server_ build sees fewer components than the client (likely for client-only components like modals, tooltips, etc.), the CSS ends up with the server build's smaller set. This could cause missing Tailwind classes in production. Needs validation in the frontend repo.
66+
### Attempted fix: disable code splitting
3967

40-
2. **First cold start in prod CI**: On a clean checkout with no existing `@source` block, `configResolved` falls back to the directory scan (bootstrap). `closeBundle` then writes `seenComponents`. If `seenComponents` is unexpectedly empty (e.g., `resolveId` didn't fire for some reason), the guard `if (seenComponents.size === 0) return` silently no-ops, leaving the bootstrapped block on disk. The next build corrects this, but CI could ship stale state on first run.
68+
Setting `outputOptions: { codeSplitting: false }` in tsdown fails:
4169

42-
3. **No integration test**: All tests are unit tests on `internals`. The plugin hooks (`resolveId`, `closeBundle`, `config`) are not tested against a real Vite instance. The two-pass behavior is unverified in an actual build.
70+
```
71+
[INVALID_OPTION] multiple inputs are not supported when "output.codeSplitting" is false
72+
```
4373

44-
4. **`resolveId` call timing in dev**: In dev, `resolveId` fires lazily as routes are visited. A user who never visits a route that imports `@ngrok/mantle/tooltip` won't get `tooltip` in the `@source` block during that session. The warm-start snapshot from the previous run covers this, but on a fresh clone + dev start, some components may be missing until that route is visited.
74+
Rolldown hard-requires a single entry point when splitting is disabled. Not viable for mantle's multi-entry build. There is no tsdown/rolldown option to inline chunks into named entries while keeping multiple entries.
4575

46-
### Recommended Next Steps Before Shipping
76+
### Fix shipped (2026-03-09)
4777

48-
1. Test in the frontend repo's `apps/www` (which has an SSR build) to validate the double-`closeBundle` behavior
49-
2. Either: track which build (`ssr` vs `client`) fired `closeBundle` and only write on the client build, or accumulate across both and write after both complete (needs a counter or `generateBundle` approach)
50-
3. Consider adding a Vite integration test using `vite.build()` in a temp fixture directory
78+
Instead of pointing `@source` at the exact entry stub, emit two patterns per component:
79+
80+
```css
81+
@source "../node_modules/@ngrok/mantle/dist/dialog.js";
82+
@source "../node_modules/@ngrok/mantle/dist/dialog-*.js";
83+
```
84+
85+
The second pattern matches the hashed code-split chunk (e.g. `dialog-BswTx6oS.js`) regardless of its hash. Tailwind scans both files: the stub (which re-exports) and the chunk (which contains the actual class strings). This sidesteps the code-splitting problem without requiring build changes.
86+
87+
A single `dialog*.js` glob was intentionally avoided because it is overly broad: `alert*.js` also matches `alert-dialog-<hash>.js`, causing Tailwind to scan unrelated components and undermining the per-component optimization.
88+
89+
---
5190

5291
## Current State
5392

54-
The plugin was **reverted to the simpler directory-scan + file-watcher implementation** pending resolution of the above gaps. `parseComponentsFromCssFile` remains in `internals.ts` as it will be needed when the module graph approach is revisited.
93+
The plugin is **fixed and operational** as of 2026-03-09. The two-pattern `@source` approach correctly discovers class strings from hashed code-split chunks without requiring build output changes.
94+
95+
Additionally:
96+
97+
- `resolveId` module-graph tracking was added, fixing Problems 1 & 2 (monorepo workspace packages and transitive mantle-internal imports).
98+
- Production builds write a precise, shrinkable set (no stale prior-run accumulation).
99+
- SSR double-build safety: server builds are skipped in `closeBundle` so the client build's complete component set is never overwritten by the server build's smaller one.
100+
101+
---
102+
103+
## Revised Direction
104+
105+
The per-entry-point `@source` model cannot be fixed without changing either the build output or the scanning strategy:
106+
107+
| Approach | Status |
108+
| ----------------------------------- | ----------------------------------------------------------------------------------------- |
109+
| `resolveId` module graph | ✅ Shipped — fixes problems 1 & 2 |
110+
| Two-pattern `@source` globs | ✅ Shipped — fixes problem 3 (`name.js` + `name-*.js` covers stub and chunk) |
111+
| `codeSplitting: false` | Blocked — rolldown rejects multiple entries with splitting disabled |
112+
| Chunk manifest (entry → chunks) | Not emitted by rolldown/tsdown; hashes change every build |
113+
| `@source "dist/"` (whole directory) | Works but is identical to `source-all.css` — zero optimization; still valid as a fallback |
114+
| Tailwind `@plugin` approach | More precise (explicit class registration, no scanning) — viable future direction |

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"fmt": "oxfmt --write",
1414
"lint:fix": "oxlint --fix",
1515
"lint": "oxlint",
16+
"install:playwright": "node ./scripts/install-playwright.js",
1617
"test": "turbo run test",
1718
"test:watch": "turbo run test:watch",
1819
"typecheck": "turbo run typecheck",
@@ -24,22 +25,23 @@
2425
"devDependencies": {
2526
"@changesets/changelog-github": "0.6.0",
2627
"@changesets/cli": "2.30.0",
27-
"@types/node": "24.12.0",
28+
"@types/node": "catalog:",
2829
"@typescript/native-preview": "7.0.0-dev.20260307.1",
2930
"husky": "9.1.7",
3031
"lint-staged": "16.3.2",
3132
"oxfmt": "0.36.0",
3233
"oxlint": "1.51.0",
33-
"tsx": "4.21.0",
34+
"tsx": "catalog:",
3435
"turbo": "2.8.14",
3536
"vitest": "4.0.18"
3637
},
3738
"engines": {
3839
"node": "^24.0.0"
3940
},
40-
"packageManager": "pnpm@10.30.3",
41+
"packageManager": "pnpm@10.31.0",
4142
"pnpm": {
4243
"overrides": {
44+
"@types/node": "24.12.0",
4345
"@mjackson/node-fetch-server": "npm:@remix-run/node-fetch-server@0.10.0",
4446
"@vitejs/plugin-react": ">=5.1.4",
4547
"cookie": ">=1.1.1",

0 commit comments

Comments
 (0)