rnwind 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +3 -1
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -1
  3. package/lib/cjs/core/parser/border-dispatcher.cjs +20 -10
  4. package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -1
  5. package/lib/cjs/core/parser/color-properties-dispatcher.cjs +7 -5
  6. package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -1
  7. package/lib/cjs/core/parser/color.cjs +194 -10
  8. package/lib/cjs/core/parser/color.cjs.map +1 -1
  9. package/lib/cjs/core/parser/color.d.ts +18 -3
  10. package/lib/cjs/core/parser/declaration.cjs +62 -4
  11. package/lib/cjs/core/parser/declaration.cjs.map +1 -1
  12. package/lib/cjs/core/parser/layout-dispatcher.cjs +32 -2
  13. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  14. package/lib/cjs/core/parser/shorthand.cjs +10 -3
  15. package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
  16. package/lib/cjs/core/parser/tokens.cjs +9 -0
  17. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  18. package/lib/cjs/core/parser/tw-parser.cjs +89 -2
  19. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  20. package/lib/cjs/core/parser/tw-parser.d.ts +2 -0
  21. package/lib/cjs/core/parser/typography-dispatcher.cjs +15 -8
  22. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  23. package/lib/cjs/core/style-builder/union-builder.cjs +81 -2
  24. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  25. package/lib/cjs/core/style-builder/union-builder.d.ts +28 -0
  26. package/lib/cjs/metro/state.cjs +74 -13
  27. package/lib/cjs/metro/state.cjs.map +1 -1
  28. package/lib/cjs/metro/state.d.ts +18 -0
  29. package/lib/cjs/metro/transformer.cjs +10 -4
  30. package/lib/cjs/metro/transformer.cjs.map +1 -1
  31. package/lib/cjs/metro/with-config.cjs +57 -0
  32. package/lib/cjs/metro/with-config.cjs.map +1 -1
  33. package/lib/cjs/metro/with-config.d.ts +12 -0
  34. package/lib/cjs/metro/wrap-imports.cjs +36 -1
  35. package/lib/cjs/metro/wrap-imports.cjs.map +1 -1
  36. package/lib/cjs/runtime/hooks/use-scheme.cjs +14 -7
  37. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
  38. package/lib/cjs/runtime/resolve.cjs +6 -2
  39. package/lib/cjs/runtime/resolve.cjs.map +1 -1
  40. package/lib/cjs/runtime/resolve.d.ts +5 -1
  41. package/lib/esm/core/normalize-classname.mjs +3 -1
  42. package/lib/esm/core/normalize-classname.mjs.map +1 -1
  43. package/lib/esm/core/parser/border-dispatcher.mjs +21 -11
  44. package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -1
  45. package/lib/esm/core/parser/color-properties-dispatcher.mjs +8 -6
  46. package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -1
  47. package/lib/esm/core/parser/color.d.ts +18 -3
  48. package/lib/esm/core/parser/color.mjs +195 -12
  49. package/lib/esm/core/parser/color.mjs.map +1 -1
  50. package/lib/esm/core/parser/declaration.mjs +63 -5
  51. package/lib/esm/core/parser/declaration.mjs.map +1 -1
  52. package/lib/esm/core/parser/layout-dispatcher.mjs +32 -2
  53. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  54. package/lib/esm/core/parser/shorthand.mjs +11 -4
  55. package/lib/esm/core/parser/shorthand.mjs.map +1 -1
  56. package/lib/esm/core/parser/tokens.mjs +10 -1
  57. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  58. package/lib/esm/core/parser/tw-parser.d.ts +2 -0
  59. package/lib/esm/core/parser/tw-parser.mjs +69 -0
  60. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  61. package/lib/esm/core/parser/typography-dispatcher.mjs +15 -8
  62. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  63. package/lib/esm/core/style-builder/union-builder.d.ts +28 -0
  64. package/lib/esm/core/style-builder/union-builder.mjs +82 -3
  65. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  66. package/lib/esm/metro/state.d.ts +18 -0
  67. package/lib/esm/metro/state.mjs +75 -14
  68. package/lib/esm/metro/state.mjs.map +1 -1
  69. package/lib/esm/metro/transformer.mjs +10 -4
  70. package/lib/esm/metro/transformer.mjs.map +1 -1
  71. package/lib/esm/metro/with-config.d.ts +12 -0
  72. package/lib/esm/metro/with-config.mjs +58 -2
  73. package/lib/esm/metro/with-config.mjs.map +1 -1
  74. package/lib/esm/metro/wrap-imports.mjs +36 -1
  75. package/lib/esm/metro/wrap-imports.mjs.map +1 -1
  76. package/lib/esm/runtime/hooks/use-scheme.mjs +14 -7
  77. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
  78. package/lib/esm/runtime/resolve.d.ts +5 -1
  79. package/lib/esm/runtime/resolve.mjs +6 -2
  80. package/lib/esm/runtime/resolve.mjs.map +1 -1
  81. package/package.json +1 -1
  82. package/src/core/normalize-classname.ts +4 -1
  83. package/src/core/parser/border-dispatcher.ts +22 -11
  84. package/src/core/parser/color-properties-dispatcher.ts +7 -5
  85. package/src/core/parser/color.ts +182 -11
  86. package/src/core/parser/declaration.ts +61 -5
  87. package/src/core/parser/layout-dispatcher.ts +34 -2
  88. package/src/core/parser/shorthand.ts +9 -3
  89. package/src/core/parser/tokens.ts +10 -1
  90. package/src/core/parser/tw-parser.ts +71 -1
  91. package/src/core/parser/typography-dispatcher.ts +15 -6
  92. package/src/core/style-builder/union-builder.ts +83 -3
  93. package/src/metro/state.ts +117 -12
  94. package/src/metro/transformer.ts +9 -4
  95. package/src/metro/with-config.ts +59 -1
  96. package/src/metro/wrap-imports.ts +36 -1
  97. package/src/runtime/hooks/use-scheme.ts +13 -6
  98. package/src/runtime/resolve.ts +6 -2
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto'
2
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import type { GradientAtomInfo, HapticRequest, KeyframeBlock, SchemedStyle, TailwindParser } from '../parser'
5
5
  import type { ThemeTables } from '../types'
@@ -8,6 +8,12 @@ import { buildSchemeSources, type AtomSerializedCache } from './build-style'
8
8
  /** Manifest module basename — the file SchemeProvider imports via the resolver. */
9
9
  const MANIFEST_BASENAME = 'schemes.js'
10
10
 
11
+ /** Suffix every per-scheme style file carries on disk. */
12
+ const SCHEME_FILE_SUFFIX = '.style.js'
13
+
14
+ /** Registry key for the always-loaded fallback scheme — never reaped. */
15
+ const COMMON_SCHEME = 'common'
16
+
11
17
  /**
12
18
  * Atomic file write — stage to a `.tmp.<pid>.<nonce>` sibling, then
13
19
  * `rename()` into place. Skips the write entirely when the existing
@@ -66,7 +72,34 @@ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
66
72
  * @returns Absolute path, e.g. `<cacheDir>/dark.style.js`.
67
73
  */
68
74
  function schemeFilePath(cacheDir: string, scheme: string): string {
69
- return path.join(cacheDir, `${scheme}.style.js`)
75
+ return path.join(cacheDir, `${scheme}${SCHEME_FILE_SUFFIX}`)
76
+ }
77
+
78
+ /**
79
+ * List scheme names whose `<scheme>.style.js` exists on disk but is NOT in
80
+ * the set the current build emits — orphans left by a removed variant
81
+ * (e.g. user drops `@variant dark`, or a theme swap via git pull). The
82
+ * always-loaded `common` scheme is never an orphan. The manifest
83
+ * (`schemes.js`) doesn't carry the `.style.js` suffix, so it's skipped.
84
+ * @param cacheDir Absolute cache directory.
85
+ * @param liveSchemes Scheme names the current build writes.
86
+ * @returns Orphaned scheme names safe to delete.
87
+ */
88
+ function findOrphanedSchemes(cacheDir: string, liveSchemes: ReadonlySet<string>): readonly string[] {
89
+ let names: readonly string[]
90
+ try {
91
+ names = readdirSync(cacheDir)
92
+ } catch {
93
+ return []
94
+ }
95
+ const orphans: string[] = []
96
+ for (const name of names) {
97
+ if (!name.endsWith(SCHEME_FILE_SUFFIX)) continue
98
+ const scheme = name.slice(0, -SCHEME_FILE_SUFFIX.length)
99
+ if (scheme === COMMON_SCHEME || liveSchemes.has(scheme)) continue
100
+ orphans.push(scheme)
101
+ }
102
+ return orphans
70
103
  }
71
104
 
72
105
  /**
@@ -156,6 +189,15 @@ class UnionBuilder {
156
189
  return this.serializedMissesCount
157
190
  }
158
191
 
192
+ /**
193
+ * Snapshot of the scheme keys currently tracked in `schemeSignatures` —
194
+ * exposed for tests to assert orphan-signature cleanup.
195
+ * @returns Scheme signature keys (includes the `__manifest` sentinel).
196
+ */
197
+ public schemeSignatureKeys(): readonly string[] {
198
+ return [...this.schemeSignatures.keys()]
199
+ }
200
+
159
201
  /**
160
202
  * Absolute path of one scheme's style file.
161
203
  * @param scheme Registry key.
@@ -285,7 +327,7 @@ class UnionBuilder {
285
327
  for (const [scheme, source] of Object.entries(schemeSources)) {
286
328
  const signature = signatureOf(source)
287
329
  const target = schemeFilePath(this.cacheDir, scheme)
288
- if (this.schemeSignatures.get(scheme) === signature && existsSync(target)) continue
330
+ if (this.canSkipWrite(scheme, signature, target, source)) continue
289
331
  if (writeIfChanged(target, source)) changed.push(scheme)
290
332
  this.schemeSignatures.set(scheme, signature)
291
333
  }
@@ -297,9 +339,47 @@ class UnionBuilder {
297
339
  this.schemeSignatures.set('__manifest', manifestSignature)
298
340
  }
299
341
 
342
+ this.reapOrphanedSchemes(new Set(Object.keys(schemeSources)))
300
343
  return { changedSchemes: changed }
301
344
  }
302
345
 
346
+ /**
347
+ * Whether the current write for one scheme can be skipped. A skip is
348
+ * safe only when the cached signature matches AND the bytes on disk
349
+ * still equal the expected source — an `existsSync` pass alone would
350
+ * keep a truncated or externally-modified file (corrupt content with a
351
+ * stale-but-matching signature). The byte read happens only on a
352
+ * signature match, so the common no-change path stays cheap.
353
+ * @param scheme Scheme registry key.
354
+ * @param signature Signature of the source about to be written.
355
+ * @param target Absolute path of the scheme file.
356
+ * @param source Expected file content.
357
+ * @returns Whether writing this scheme can be skipped.
358
+ */
359
+ private canSkipWrite(scheme: string, signature: string, target: string, source: string): boolean {
360
+ if (this.schemeSignatures.get(scheme) !== signature) return false
361
+ try {
362
+ return readFileSync(target, 'utf8') === source
363
+ } catch {
364
+ // Missing or unreadable on disk — must rewrite.
365
+ return false
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Delete `<scheme>.style.js` files left behind by a scheme that's no
371
+ * longer part of the build (removed `@variant`, theme swap), and drop
372
+ * their cached signatures so a later re-introduction rewrites cleanly.
373
+ * The `common` file and the manifest are never touched.
374
+ * @param liveSchemes Scheme names the current build wrote.
375
+ */
376
+ private reapOrphanedSchemes(liveSchemes: ReadonlySet<string>): void {
377
+ for (const scheme of findOrphanedSchemes(this.cacheDir, liveSchemes)) {
378
+ rmSync(schemeFilePath(this.cacheDir, scheme), { force: true })
379
+ this.schemeSignatures.delete(scheme)
380
+ }
381
+ }
382
+
303
383
  /**
304
384
  * Ensure the manifest + common scheme files exist on disk so Metro's
305
385
  * resolver can SHA1 them at boot before the first transform runs.
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { UnionBuilder } from '../core/style-builder'
@@ -59,22 +59,91 @@ let libraryFingerprint: string | undefined
59
59
  /** Live state shared across one Metro transform worker. */
60
60
  let cached: RnwindState | null = null
61
61
 
62
+ /**
63
+ * Cached wrap-module map. The env var feeding it only changes on a Metro
64
+ * (re)configure, so rebuilding the 11-entry Map per file transform is pure
65
+ * waste — `configureRnwindState` / `resetRnwindState` clear this so the next
66
+ * call rebuilds from the current env.
67
+ */
68
+ let cachedWrapModules: ReturnType<typeof buildWrapModules> | null = null
69
+
70
+ /** A memoised theme read: the resolved CSS, its content hash, and its mtime. */
71
+ interface ThemeMemoEntry {
72
+ mtimeMs: number
73
+ hash: string
74
+ css: string
75
+ }
76
+
77
+ /**
78
+ * Process-scoped memo of resolved theme CSS keyed by entry path. Every file
79
+ * transform reads the theme hash (via `getRnwindState` + `getRnwindCacheKey`)
80
+ * and the rebuild path reads the full CSS again — without this memo each is a
81
+ * full disk read + `@import` inline + SHA-256. Keyed on `statSync` mtime so a
82
+ * real edit busts it while unchanged files reuse the cached result. This is
83
+ * NOT a competing persistent cache: it's in-memory, process-scoped, and only
84
+ * defers the redundant re-reads Metro's own caching can't see.
85
+ */
86
+ const themeMemo = new Map<string, ThemeMemoEntry>()
87
+
88
+ /** Test-only counter — increments on each actual disk read (memo miss). */
89
+ let themeReadCount = 0
90
+
91
+ /**
92
+ * Load (or reuse) the resolved theme CSS + hash for `cssPath`. Re-reads from
93
+ * disk only when the file's mtime changed since the last read; otherwise the
94
+ * cached entry is returned untouched.
95
+ * @param cssPath Absolute CSS entry path.
96
+ * @returns The memo entry, or `null` when the entry can't be read.
97
+ */
98
+ function loadThemeMemo(cssPath: string): ThemeMemoEntry | null {
99
+ let stat: ReturnType<typeof statSync>
100
+ try {
101
+ stat = statSync(cssPath)
102
+ } catch {
103
+ themeMemo.delete(cssPath)
104
+ return null
105
+ }
106
+ const { mtimeMs } = stat
107
+ const cachedEntry = themeMemo.get(cssPath)
108
+ if (cachedEntry?.mtimeMs === mtimeMs) return cachedEntry
109
+ const css = resolveThemeCss(cssPath)
110
+ themeReadCount += 1
111
+ const entry: ThemeMemoEntry = { mtimeMs, hash: createHash('sha256').update(css).digest('hex').slice(0, 16), css }
112
+ themeMemo.set(cssPath, entry)
113
+ return entry
114
+ }
115
+
62
116
  /**
63
117
  * Cheap content-hash readout. SHA-256 prefix of the FULLY-RESOLVED theme
64
118
  * CSS — `@import`s flattened — so an edit to a theme file the entry only
65
119
  * re-exports (`@import "@acme/ui/theme.css"`) still rotates the hash and
66
120
  * invalidates Metro's cache. Returns `'missing'` when the entry can't be
67
- * read so the cache key stays deterministic.
121
+ * read so the cache key stays deterministic. Memoised by mtime.
68
122
  * @param cssPath Absolute CSS path.
69
123
  * @returns 16-char hex content hash.
70
124
  */
71
125
  function readThemeHashFor(cssPath: string): string {
72
- if (!existsSync(cssPath)) return 'missing'
73
- try {
74
- return createHash('sha256').update(resolveThemeCss(cssPath)).digest('hex').slice(0, 16)
75
- } catch {
76
- return 'missing'
77
- }
126
+ return loadThemeMemo(cssPath)?.hash ?? 'missing'
127
+ }
128
+
129
+ /**
130
+ * Resolved theme CSS for the entry, served from the same mtime memo so the
131
+ * rebuild path doesn't re-read the file the hash readout already loaded.
132
+ * @param cssPath Absolute CSS path.
133
+ * @returns Fully-inlined theme CSS, or `''` when unreadable.
134
+ */
135
+ function readThemeCssFor(cssPath: string): string {
136
+ return loadThemeMemo(cssPath)?.css ?? ''
137
+ }
138
+
139
+ /** Test-only — count of actual disk reads of the theme CSS (memo misses). */
140
+ export function __getThemeReadCount(): number {
141
+ return themeReadCount
142
+ }
143
+
144
+ /** Test-only — clear the theme memo so the next read hits disk. */
145
+ export function __resetThemeMemo(): void {
146
+ themeMemo.clear()
78
147
  }
79
148
 
80
149
  /**
@@ -127,6 +196,17 @@ function getLibraryFingerprint(): string {
127
196
  return libraryFingerprint
128
197
  }
129
198
 
199
+ /**
200
+ * Test-only override for the memoised library fingerprint. Production reads
201
+ * the value once from disk; tests use this to simulate a library upgrade
202
+ * (fingerprint rotation) without rebuilding the lib. Passing `undefined`
203
+ * clears the override so the next call re-derives from disk.
204
+ * @param value Forced fingerprint, or omit/`undefined` to clear the override.
205
+ */
206
+ export function __setLibraryFingerprintForTest(value?: string): void {
207
+ libraryFingerprint = value
208
+ }
209
+
130
210
  /**
131
211
  * Worker-local state. Lazy-initialised on first access so files that
132
212
  * bypass the transform don't pay for construction.
@@ -136,6 +216,12 @@ export interface RnwindState {
136
216
  builder: UnionBuilder
137
217
  themeCss: string
138
218
  themeHash: string
219
+ /**
220
+ * Library fingerprint captured when this state was built. Folded into the
221
+ * rebuild guard so an in-place rnwind upgrade (unchanged CSS, rotated
222
+ * fingerprint) drops the stale builder + on-disk scheme format.
223
+ */
224
+ libraryFingerprint: string
139
225
  projectRoot: string
140
226
  }
141
227
 
@@ -167,6 +253,7 @@ export function configureRnwindState(
167
253
  process.env[WRAP_MODULES_ENV] = wrapModules.join(',')
168
254
  }
169
255
  cached = null
256
+ cachedWrapModules = null
170
257
  }
171
258
 
172
259
  /**
@@ -175,9 +262,11 @@ export function configureRnwindState(
175
262
  * @returns Module → policy map the import-rewrite consults.
176
263
  */
177
264
  export function getWrapModules(): ReturnType<typeof buildWrapModules> {
265
+ if (cachedWrapModules) return cachedWrapModules
178
266
  const raw = process.env[WRAP_MODULES_ENV]
179
267
  const extra = raw && raw.length > 0 ? raw.split(',').filter((entry) => entry.length > 0) : undefined
180
- return buildWrapModules(extra)
268
+ cachedWrapModules = buildWrapModules(extra)
269
+ return cachedWrapModules
181
270
  }
182
271
 
183
272
  /**
@@ -196,14 +285,26 @@ export function getRnwindState(projectRoot: string): RnwindState {
196
285
  if (!cssEntry) throw new Error('rnwind: RNWIND_CSS_ENTRY_FILE is not set — did `withRnwindConfig` run?')
197
286
  if (!cacheDir) throw new Error('rnwind: RNWIND_CACHE_DIR is not set — did `withRnwindConfig` run?')
198
287
  const currentHash = readThemeHashFor(cssEntry)
199
- if (cached?.themeHash === currentHash && cached.projectRoot === projectRoot) return cached
200
- const themeCss = resolveThemeCss(cssEntry)
288
+ const currentFingerprint = getLibraryFingerprint()
289
+ // Reuse only when the CSS hash, the project root, AND the library
290
+ // fingerprint all match — an in-place rnwind upgrade rotates the
291
+ // fingerprint while the CSS hash is unchanged, and that must still rebuild.
292
+ if (
293
+ cached?.themeHash === currentHash &&
294
+ cached.libraryFingerprint === currentFingerprint &&
295
+ cached.projectRoot === projectRoot
296
+ ) {
297
+ return cached
298
+ }
299
+ // Served from the same mtime memo `readThemeHashFor` just populated — no
300
+ // second disk read on the rebuild path.
301
+ const themeCss = readThemeCssFor(cssEntry)
201
302
  const parser = new TailwindParser({
202
303
  themeCss,
203
304
  sources: defaultSources(projectRoot, cacheDir, readWatchFolders()),
204
305
  })
205
306
  const builder = new UnionBuilder(cacheDir, parser)
206
- cached = { parser, builder, themeCss, themeHash: currentHash, projectRoot }
307
+ cached = { parser, builder, themeCss, themeHash: currentHash, libraryFingerprint: currentFingerprint, projectRoot }
207
308
  return cached
208
309
  }
209
310
 
@@ -229,6 +330,7 @@ export function getRnwindCacheKey(): string {
229
330
  /** Drop the cached state — call after editing the theme CSS. */
230
331
  export function resetRnwindState(): void {
231
332
  cached = null
333
+ cachedWrapModules = null
232
334
  }
233
335
 
234
336
  /**
@@ -241,6 +343,9 @@ export function resetRnwindState(): void {
241
343
  * @param projectRoot Absolute project root (from `metroConfig.projectRoot`).
242
344
  */
243
345
  export async function onThemeChange(projectRoot: string): Promise<void> {
346
+ // The watcher already told us the CSS changed; an atomic save can reuse the
347
+ // same mtime, so bust the memo unconditionally rather than trusting stat.
348
+ themeMemo.clear()
244
349
  resetRnwindState()
245
350
  const state = getRnwindState(projectRoot)
246
351
  await state.builder.writeSchemes()
@@ -174,13 +174,18 @@ async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
174
174
  // Wrap host imports ONLY when className flows through a component — written
175
175
  // (`hasClassName`) or forwarded (`{...rest}`). A `useCss`/`cva`-only file
176
176
  // resolves manually, so its imports (e.g. skia drawing primitives) are
177
- // left untouched.
178
- const wrapped = hasClassName || hasSpread ? rewriteWrapImports(ast, getWrapModules()) : false
177
+ // left untouched. Mutates the AST in place; both branches below regenerate.
178
+ if (hasClassName || hasSpread) rewriteWrapImports(ast, getWrapModules())
179
179
 
180
180
  if (!hasAtoms) {
181
- // Wrap-only forwarder — no classes to record/register.
181
+ // Wrap-only forwarder — no classes to record/register, but className
182
+ // still flows through it (`hasClassName || hasSpread`). It MUST hold a
183
+ // dep-graph edge to the theme CSS so a theme edit re-transforms it and
184
+ // its forwarded className resolves against the new scheme — otherwise it
185
+ // renders stale. Inject the theme-signature import even with no atoms.
182
186
  state.builder.dropFile(args.filename)
183
- return wrapped ? generateModule(ast).code : args.src
187
+ injectThemeSignatureImport(ast)
188
+ return generateModule(ast).code
184
189
  }
185
190
 
186
191
  warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename, [parsed.gradientAtoms, parsed.hapticAtoms])
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, watch as watchFile } from 'node:fs'
1
+ import { existsSync, mkdirSync, rmSync, watch as watchFile } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { writeDtsFile } from './dts'
4
4
  import { createRnwindResolver, type ResolveRequestFn } from './resolver'
@@ -79,6 +79,58 @@ function resolveCacheDir(projectRoot: string, override: string | undefined): str
79
79
  return path.isAbsolute(override) ? override : path.resolve(projectRoot, override)
80
80
  }
81
81
 
82
+ /** Cache dirs already wiped this process — guards the reset so it fires once per dir, not on every worker (re)configure. */
83
+ const wipedCacheDirs = new Set<string>()
84
+
85
+ /**
86
+ * CLI flags that mean "reset the cache". `metro.config.js` (→ this function)
87
+ * is evaluated BEFORE Metro/Expo merge the flag onto the config object
88
+ * (`overrideConfigWithArguments` / Expo's `instantiateMetro` both set
89
+ * `resetCache` AFTER loading the config), so `config.resetCache` is still its
90
+ * `false` default here. The reliable signal at config-eval time is the CLI
91
+ * argv of the same process: `react-native start --reset-cache`, Metro
92
+ * `--reset-cache`, and Expo `start -c` / `--clear`.
93
+ */
94
+ const RESET_CACHE_ARGS: ReadonlySet<string> = new Set(['--reset-cache', '--resetCache', '--clear', '-c'])
95
+
96
+ /**
97
+ * Whether the dev asked for a cache reset — true if the (post-merge) config
98
+ * flag is set OR the CLI argv carries a reset flag. Wiping on a false positive
99
+ * is harmless (the cache just regenerates), so argv detection errs toward
100
+ * honoring the request.
101
+ * @param resetCache Metro's `config.resetCache` (usually still false at config-eval time).
102
+ * @param argv Process argv (injectable for tests).
103
+ * @returns True when the cache dir should be wiped.
104
+ */
105
+ export function isResetCacheRequested(resetCache: boolean | undefined, argv: readonly string[] = process.argv): boolean {
106
+ if (resetCache === true) return true
107
+ return argv.some((argument) => RESET_CACHE_ARGS.has(argument))
108
+ }
109
+
110
+ /**
111
+ * On a Metro `--reset-cache` run (Expo `start -c` / `--clear`) the dev
112
+ * explicitly asked for a clean slate, so rnwind's generated cache dir
113
+ * (`*.style.js` scheme chunks + `schemes.js` manifest) is stale and must be
114
+ * wiped before `ensureFilesExist` regenerates it — otherwise old chunks
115
+ * survive a reset and Metro's haste-map indexes dead bytes. Only ever removes
116
+ * the resolved cache dir rnwind owns, never the project root, and never throws
117
+ * when the dir is already absent. Idempotent per dir within one process.
118
+ * @param cacheDir Absolute path to rnwind's cache dir.
119
+ * @param resetCache Metro's resolved `config.resetCache` flag.
120
+ * @param projectRoot Absolute project root — guards against ever wiping it.
121
+ */
122
+ function resetCacheDirIfRequested(cacheDir: string, resetCache: boolean | undefined, projectRoot: string): void {
123
+ if (!isResetCacheRequested(resetCache)) return
124
+ if (wipedCacheDirs.has(cacheDir)) return
125
+ // Hard safety: never rm the project root (or an ancestor of it) even if a
126
+ // mis-configured cacheDir resolves there.
127
+ const resolvedCache = path.resolve(cacheDir)
128
+ const resolvedRoot = path.resolve(projectRoot)
129
+ if (resolvedCache === resolvedRoot || resolvedRoot.startsWith(resolvedCache + path.sep)) return
130
+ wipedCacheDirs.add(cacheDir)
131
+ rmSync(resolvedCache, { recursive: true, force: true })
132
+ }
133
+
82
134
  /**
83
135
  * Read the theme CSS and extract `@variant <name>` blocks for the .d.ts
84
136
  * generator. Forces construction of `getRnwindState`, then reads
@@ -128,6 +180,8 @@ export interface RnwindMetroOptions {
128
180
  export interface MetroConfigLike {
129
181
  projectRoot?: string
130
182
  watchFolders?: string[]
183
+ /** Set by Metro when the dev runs `--reset-cache` (Expo `start -c`). Wipes rnwind's cache dir. */
184
+ resetCache?: boolean
131
185
  transformer?: {
132
186
  babelTransformerPath?: string
133
187
  [key: string]: unknown
@@ -166,6 +220,10 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
166
220
  const cacheDir = resolveCacheDir(projectRoot, options.cacheDir)
167
221
  const cssEntry = path.isAbsolute(options.cssEntryFile) ? options.cssEntryFile : path.resolve(projectRoot, options.cssEntryFile)
168
222
 
223
+ // Honor Metro's `--reset-cache`: wipe rnwind's stale generated dir BEFORE we
224
+ // recreate it + before `ensureFilesExist` regenerates and the haste-map
225
+ // indexes it. Synchronous so the dir is clean by the time this returns.
226
+ resetCacheDirIfRequested(cacheDir, metroConfig.resetCache, projectRoot)
169
227
  mkdirSync(cacheDir, { recursive: true })
170
228
  const watchFolders = (metroConfig.watchFolders ?? []).filter((p) => typeof p === 'string' && p.length > 0)
171
229
  configureRnwindState(cssEntry, cacheDir, watchFolders, options.wrapModules)
@@ -84,8 +84,26 @@ const NON_COMPONENT_EXPORTS: ReadonlySet<string> = new Set([
84
84
  'Easing',
85
85
  'ReduceMotion',
86
86
  'KeyframeRegistry',
87
+ // gesture-handler enums (PascalCase, but value objects accessed as
88
+ // `PointerType.TOUCH` / `MouseButton.LEFT`) — wrapping breaks the access.
89
+ 'PointerType',
90
+ 'MouseButton',
91
+ 'HoverEffect',
87
92
  ])
88
93
 
94
+ /**
95
+ * react-native-reanimated's NAMED exports are hooks, worklets, animation
96
+ * builders (`FadeIn`, `ZoomIn`, `Keyframe`, `Layout`…), easing helpers and
97
+ * enums (`SensorType`…) — NOT className-styleable components. Its only
98
+ * styleable surface is the DEFAULT `Animated` namespace, wrapped separately
99
+ * via {@link NAMESPACE_DEFAULT_MODULES}. Under the `'all'` policy the
100
+ * PascalCase heuristic wrongly wrapped builders/enums into `wrap()`-ed
101
+ * functions, so `FadeIn.duration()` / `new Keyframe()` / `SensorType.X` threw
102
+ * "is not a function" / "is not a constructor" / read `undefined`. An empty
103
+ * allow-list wraps none of them while leaving the default namespace intact.
104
+ */
105
+ const REANIMATED_NAMED_COMPONENTS: ReadonlySet<string> = new Set()
106
+
89
107
  /** Per-module policy: an explicit allow-list, or `'all'` named exports. */
90
108
  export type WrapPolicy = 'all' | ReadonlySet<string>
91
109
 
@@ -97,7 +115,7 @@ export type WrapPolicy = 'all' | ReadonlySet<string>
97
115
  */
98
116
  export const DEFAULT_WRAP_MODULES: ReadonlyMap<string, WrapPolicy> = new Map<string, WrapPolicy>([
99
117
  ['react-native', REACT_NATIVE_COMPONENTS],
100
- ['react-native-reanimated', 'all'],
118
+ ['react-native-reanimated', REANIMATED_NAMED_COMPONENTS],
101
119
  ['react-native-svg', 'all'],
102
120
  ['react-native-gesture-handler', 'all'],
103
121
  ['react-native-safe-area-context', 'all'],
@@ -151,6 +169,19 @@ function importedNameOf(specifier: t.ImportSpecifier): string {
151
169
  return t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value
152
170
  }
153
171
 
172
+ /**
173
+ * Whether an import kind is type-only (`import type …` / `import { type … }`,
174
+ * and the Flow `typeof` variants). Type-only bindings carry no runtime value —
175
+ * preset-typescript strips them from the bundle — so wrapping one emits a
176
+ * `const X = wrap(_rnwN)` referencing a binding that no longer exists at
177
+ * runtime → Hermes "Property '_rnwN' doesn't exist". They must never be wrapped.
178
+ * @param kind The `importKind` of a declaration or specifier.
179
+ * @returns True when the import is type-only.
180
+ */
181
+ function isTypeOnly(kind: t.ImportDeclaration['importKind'] | t.ImportSpecifier['importKind']): boolean {
182
+ return kind === 'type' || kind === 'typeof'
183
+ }
184
+
154
185
  /**
155
186
  * Build `const Local = <wrapper>(alias)` and rebind the specifier's local
156
187
  * to `alias` in place.
@@ -193,8 +224,12 @@ function wrapSpecifiers(
193
224
  const moduleName = node.source.value
194
225
  let next = counter
195
226
  let usesNamespace = false
227
+ // `import type { … }` — whole declaration is type-only; nothing to wrap.
228
+ if (isTypeOnly(node.importKind)) return { decls, counter: next, usesNamespace }
196
229
  for (const specifier of node.specifiers) {
197
230
  if (t.isImportSpecifier(specifier)) {
231
+ // `import { type X }` — inline type-only specifier; skip it.
232
+ if (isTypeOnly(specifier.importKind)) continue
198
233
  if (!shouldWrap(policy, importedNameOf(specifier))) continue
199
234
  decls.push(makeWrapDecl(specifier, `_rnw${next}`, WRAP_LOCAL))
200
235
  next += 1
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react'
1
2
  import type { ThemeTable } from '../../core/types'
2
3
  import { useRnwind } from '../components/rnwind-provider'
3
4
  import { getThemeTokens } from '../lookup-css'
@@ -23,13 +24,19 @@ export function useTheme(): ThemeTable {
23
24
  const { scheme, tables } = useRnwind()
24
25
  // The build registers token tables on the manifest so `useColor` works out
25
26
  // of the box; an explicit `tables` prop layers on top (the prop wins).
27
+ // `getThemeTokens()` REPLACES its map on registration, so its identity is
28
+ // stable between (HMR) registers — a sound memo dep. Memoizing keeps the
29
+ // merged table a STABLE reference across renders, so every `useColor` /
30
+ // `useToken` / `useSize` call avoids re-allocating 2–3 objects per render.
26
31
  const registered = getThemeTokens()
27
- const base = { ...registered[BASE_SCHEME], ...tables[BASE_SCHEME] }
28
- const schemeTable = { ...registered[scheme], ...tables[scheme] }
29
- // Base tokens apply everywhere (CSS `:root` cascade); the active scheme's
30
- // own entries override on overlap.
31
- if (Object.keys(schemeTable).length === 0) return base
32
- return { ...base, ...schemeTable }
32
+ return useMemo(() => {
33
+ const base = { ...registered[BASE_SCHEME], ...tables[BASE_SCHEME] }
34
+ const schemeTable = { ...registered[scheme], ...tables[scheme] }
35
+ // Base tokens apply everywhere (CSS `:root` cascade); the active scheme's
36
+ // own entries override on overlap.
37
+ if (Object.keys(schemeTable).length === 0) return base
38
+ return { ...base, ...schemeTable }
39
+ }, [scheme, tables, registered])
33
40
  }
34
41
 
35
42
  /**
@@ -109,12 +109,16 @@ const DIRECTION_POINTS: Record<GradientDirection, { start: GradientPoint; end: G
109
109
 
110
110
  /**
111
111
  * Register one scheme's pre-merged molecules (atom-merged literal
112
- * classNames). Merges onto any existing entries for the scheme.
112
+ * classNames). REPLACES the scheme's map each generated scheme file emits a
113
+ * single `registerMolecules(scheme, …)` carrying the complete molecule set
114
+ * (mirrors `registerAtoms`). Merging instead would leak molecules removed in a
115
+ * Fast Refresh edit, so a deleted/renamed className would keep resolving to its
116
+ * stale pre-edit style.
113
117
  * @param scheme Scheme name (or `'common'`).
114
118
  * @param entries Normalized className → merged style object.
115
119
  */
116
120
  export function registerMolecules(scheme: string, entries: Record<string, unknown>): void {
117
- molecules[scheme] = { ...molecules[scheme], ...entries }
121
+ molecules[scheme] = entries
118
122
  registryVersion += 1
119
123
  }
120
124