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.
- package/lib/cjs/core/normalize-classname.cjs +3 -1
- package/lib/cjs/core/normalize-classname.cjs.map +1 -1
- package/lib/cjs/core/parser/border-dispatcher.cjs +20 -10
- package/lib/cjs/core/parser/border-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs +7 -5
- package/lib/cjs/core/parser/color-properties-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/color.cjs +194 -10
- package/lib/cjs/core/parser/color.cjs.map +1 -1
- package/lib/cjs/core/parser/color.d.ts +18 -3
- package/lib/cjs/core/parser/declaration.cjs +62 -4
- package/lib/cjs/core/parser/declaration.cjs.map +1 -1
- package/lib/cjs/core/parser/layout-dispatcher.cjs +32 -2
- package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/parser/shorthand.cjs +10 -3
- package/lib/cjs/core/parser/shorthand.cjs.map +1 -1
- package/lib/cjs/core/parser/tokens.cjs +9 -0
- package/lib/cjs/core/parser/tokens.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.cjs +89 -2
- package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
- package/lib/cjs/core/parser/tw-parser.d.ts +2 -0
- package/lib/cjs/core/parser/typography-dispatcher.cjs +15 -8
- package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.cjs +81 -2
- package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
- package/lib/cjs/core/style-builder/union-builder.d.ts +28 -0
- package/lib/cjs/metro/state.cjs +74 -13
- package/lib/cjs/metro/state.cjs.map +1 -1
- package/lib/cjs/metro/state.d.ts +18 -0
- package/lib/cjs/metro/transformer.cjs +10 -4
- package/lib/cjs/metro/transformer.cjs.map +1 -1
- package/lib/cjs/metro/with-config.cjs +57 -0
- package/lib/cjs/metro/with-config.cjs.map +1 -1
- package/lib/cjs/metro/with-config.d.ts +12 -0
- package/lib/cjs/metro/wrap-imports.cjs +36 -1
- package/lib/cjs/metro/wrap-imports.cjs.map +1 -1
- package/lib/cjs/runtime/hooks/use-scheme.cjs +14 -7
- package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.cjs +6 -2
- package/lib/cjs/runtime/resolve.cjs.map +1 -1
- package/lib/cjs/runtime/resolve.d.ts +5 -1
- package/lib/esm/core/normalize-classname.mjs +3 -1
- package/lib/esm/core/normalize-classname.mjs.map +1 -1
- package/lib/esm/core/parser/border-dispatcher.mjs +21 -11
- package/lib/esm/core/parser/border-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color-properties-dispatcher.mjs +8 -6
- package/lib/esm/core/parser/color-properties-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/color.d.ts +18 -3
- package/lib/esm/core/parser/color.mjs +195 -12
- package/lib/esm/core/parser/color.mjs.map +1 -1
- package/lib/esm/core/parser/declaration.mjs +63 -5
- package/lib/esm/core/parser/declaration.mjs.map +1 -1
- package/lib/esm/core/parser/layout-dispatcher.mjs +32 -2
- package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
- package/lib/esm/core/parser/shorthand.mjs +11 -4
- package/lib/esm/core/parser/shorthand.mjs.map +1 -1
- package/lib/esm/core/parser/tokens.mjs +10 -1
- package/lib/esm/core/parser/tokens.mjs.map +1 -1
- package/lib/esm/core/parser/tw-parser.d.ts +2 -0
- package/lib/esm/core/parser/tw-parser.mjs +69 -0
- package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
- package/lib/esm/core/parser/typography-dispatcher.mjs +15 -8
- package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
- package/lib/esm/core/style-builder/union-builder.d.ts +28 -0
- package/lib/esm/core/style-builder/union-builder.mjs +82 -3
- package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
- package/lib/esm/metro/state.d.ts +18 -0
- package/lib/esm/metro/state.mjs +75 -14
- package/lib/esm/metro/state.mjs.map +1 -1
- package/lib/esm/metro/transformer.mjs +10 -4
- package/lib/esm/metro/transformer.mjs.map +1 -1
- package/lib/esm/metro/with-config.d.ts +12 -0
- package/lib/esm/metro/with-config.mjs +58 -2
- package/lib/esm/metro/with-config.mjs.map +1 -1
- package/lib/esm/metro/wrap-imports.mjs +36 -1
- package/lib/esm/metro/wrap-imports.mjs.map +1 -1
- package/lib/esm/runtime/hooks/use-scheme.mjs +14 -7
- package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
- package/lib/esm/runtime/resolve.d.ts +5 -1
- package/lib/esm/runtime/resolve.mjs +6 -2
- package/lib/esm/runtime/resolve.mjs.map +1 -1
- package/package.json +1 -1
- package/src/core/normalize-classname.ts +4 -1
- package/src/core/parser/border-dispatcher.ts +22 -11
- package/src/core/parser/color-properties-dispatcher.ts +7 -5
- package/src/core/parser/color.ts +182 -11
- package/src/core/parser/declaration.ts +61 -5
- package/src/core/parser/layout-dispatcher.ts +34 -2
- package/src/core/parser/shorthand.ts +9 -3
- package/src/core/parser/tokens.ts +10 -1
- package/src/core/parser/tw-parser.ts +71 -1
- package/src/core/parser/typography-dispatcher.ts +15 -6
- package/src/core/style-builder/union-builder.ts +83 -3
- package/src/metro/state.ts +117 -12
- package/src/metro/transformer.ts +9 -4
- package/src/metro/with-config.ts +59 -1
- package/src/metro/wrap-imports.ts +36 -1
- package/src/runtime/hooks/use-scheme.ts +13 -6
- 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}
|
|
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.
|
|
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.
|
package/src/metro/state.ts
CHANGED
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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()
|
package/src/metro/transformer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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])
|
package/src/metro/with-config.ts
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
/**
|
package/src/runtime/resolve.ts
CHANGED
|
@@ -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).
|
|
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] =
|
|
121
|
+
molecules[scheme] = entries
|
|
118
122
|
registryVersion += 1
|
|
119
123
|
}
|
|
120
124
|
|