rnwind 0.0.7 → 0.0.9

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 (131) hide show
  1. package/lib/cjs/core/parser/color.cjs +33 -1
  2. package/lib/cjs/core/parser/color.cjs.map +1 -1
  3. package/lib/cjs/core/parser/color.d.ts +10 -0
  4. package/lib/cjs/core/parser/declaration.cjs +161 -10
  5. package/lib/cjs/core/parser/declaration.cjs.map +1 -1
  6. package/lib/cjs/core/parser/gradient.cjs +46 -12
  7. package/lib/cjs/core/parser/gradient.cjs.map +1 -1
  8. package/lib/cjs/core/parser/gradient.d.ts +2 -1
  9. package/lib/cjs/core/parser/keyframes.cjs +27 -12
  10. package/lib/cjs/core/parser/keyframes.cjs.map +1 -1
  11. package/lib/cjs/core/parser/keyframes.d.ts +11 -0
  12. package/lib/cjs/core/parser/layout-dispatcher.cjs +33 -10
  13. package/lib/cjs/core/parser/layout-dispatcher.cjs.map +1 -1
  14. package/lib/cjs/core/parser/length.cjs +17 -1
  15. package/lib/cjs/core/parser/length.cjs.map +1 -1
  16. package/lib/cjs/core/parser/safe-area.cjs +24 -3
  17. package/lib/cjs/core/parser/safe-area.cjs.map +1 -1
  18. package/lib/cjs/core/parser/theme-vars.cjs +58 -8
  19. package/lib/cjs/core/parser/theme-vars.cjs.map +1 -1
  20. package/lib/cjs/core/parser/tokens.cjs +77 -9
  21. package/lib/cjs/core/parser/tokens.cjs.map +1 -1
  22. package/lib/cjs/core/parser/tokens.d.ts +9 -0
  23. package/lib/cjs/core/parser/transform.cjs +18 -9
  24. package/lib/cjs/core/parser/transform.cjs.map +1 -1
  25. package/lib/cjs/core/parser/tw-parser.cjs +93 -33
  26. package/lib/cjs/core/parser/tw-parser.cjs.map +1 -1
  27. package/lib/cjs/core/parser/typography-dispatcher.cjs +19 -1
  28. package/lib/cjs/core/parser/typography-dispatcher.cjs.map +1 -1
  29. package/lib/cjs/core/parser/typography.cjs +15 -18
  30. package/lib/cjs/core/parser/typography.cjs.map +1 -1
  31. package/lib/cjs/core/parser/typography.d.ts +5 -5
  32. package/lib/cjs/core/style-builder/union-builder.cjs +0 -10
  33. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  34. package/lib/cjs/core/style-builder/union-builder.d.ts +0 -8
  35. package/lib/cjs/metro/dts.cjs +6 -1
  36. package/lib/cjs/metro/dts.cjs.map +1 -1
  37. package/lib/cjs/metro/transformer.cjs +43 -60
  38. package/lib/cjs/metro/transformer.cjs.map +1 -1
  39. package/lib/cjs/metro/with-config.cjs +9 -29
  40. package/lib/cjs/metro/with-config.cjs.map +1 -1
  41. package/lib/cjs/runtime/hooks/use-scheme.cjs +9 -6
  42. package/lib/cjs/runtime/hooks/use-scheme.cjs.map +1 -1
  43. package/lib/cjs/runtime/hooks/use-scheme.d.ts +7 -4
  44. package/lib/cjs/runtime/index.cjs +1 -1
  45. package/lib/cjs/runtime/index.cjs.map +1 -1
  46. package/lib/cjs/runtime/index.d.ts +1 -1
  47. package/lib/cjs/runtime/lookup-css.cjs +14 -0
  48. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  49. package/lib/cjs/runtime/lookup-css.d.ts +11 -0
  50. package/lib/cjs/runtime/resolve.cjs +8 -6
  51. package/lib/cjs/runtime/resolve.cjs.map +1 -1
  52. package/lib/cjs/runtime/wrap.cjs +50 -57
  53. package/lib/cjs/runtime/wrap.cjs.map +1 -1
  54. package/lib/cjs/runtime/wrap.d.ts +10 -4
  55. package/lib/esm/core/parser/color.d.ts +10 -0
  56. package/lib/esm/core/parser/color.mjs +33 -2
  57. package/lib/esm/core/parser/color.mjs.map +1 -1
  58. package/lib/esm/core/parser/declaration.mjs +162 -11
  59. package/lib/esm/core/parser/declaration.mjs.map +1 -1
  60. package/lib/esm/core/parser/gradient.d.ts +2 -1
  61. package/lib/esm/core/parser/gradient.mjs +45 -11
  62. package/lib/esm/core/parser/gradient.mjs.map +1 -1
  63. package/lib/esm/core/parser/keyframes.d.ts +11 -0
  64. package/lib/esm/core/parser/keyframes.mjs +27 -12
  65. package/lib/esm/core/parser/keyframes.mjs.map +1 -1
  66. package/lib/esm/core/parser/layout-dispatcher.mjs +33 -10
  67. package/lib/esm/core/parser/layout-dispatcher.mjs.map +1 -1
  68. package/lib/esm/core/parser/length.mjs +17 -1
  69. package/lib/esm/core/parser/length.mjs.map +1 -1
  70. package/lib/esm/core/parser/safe-area.mjs +24 -3
  71. package/lib/esm/core/parser/safe-area.mjs.map +1 -1
  72. package/lib/esm/core/parser/theme-vars.mjs +58 -8
  73. package/lib/esm/core/parser/theme-vars.mjs.map +1 -1
  74. package/lib/esm/core/parser/tokens.d.ts +9 -0
  75. package/lib/esm/core/parser/tokens.mjs +77 -10
  76. package/lib/esm/core/parser/tokens.mjs.map +1 -1
  77. package/lib/esm/core/parser/transform.mjs +18 -9
  78. package/lib/esm/core/parser/transform.mjs.map +1 -1
  79. package/lib/esm/core/parser/tw-parser.mjs +95 -35
  80. package/lib/esm/core/parser/tw-parser.mjs.map +1 -1
  81. package/lib/esm/core/parser/typography-dispatcher.mjs +19 -1
  82. package/lib/esm/core/parser/typography-dispatcher.mjs.map +1 -1
  83. package/lib/esm/core/parser/typography.d.ts +5 -5
  84. package/lib/esm/core/parser/typography.mjs +15 -18
  85. package/lib/esm/core/parser/typography.mjs.map +1 -1
  86. package/lib/esm/core/style-builder/union-builder.d.ts +0 -8
  87. package/lib/esm/core/style-builder/union-builder.mjs +0 -10
  88. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  89. package/lib/esm/metro/dts.mjs +6 -1
  90. package/lib/esm/metro/dts.mjs.map +1 -1
  91. package/lib/esm/metro/transformer.mjs +43 -60
  92. package/lib/esm/metro/transformer.mjs.map +1 -1
  93. package/lib/esm/metro/with-config.mjs +10 -30
  94. package/lib/esm/metro/with-config.mjs.map +1 -1
  95. package/lib/esm/runtime/hooks/use-scheme.d.ts +7 -4
  96. package/lib/esm/runtime/hooks/use-scheme.mjs +9 -6
  97. package/lib/esm/runtime/hooks/use-scheme.mjs.map +1 -1
  98. package/lib/esm/runtime/index.d.ts +1 -1
  99. package/lib/esm/runtime/index.mjs +1 -1
  100. package/lib/esm/runtime/index.mjs.map +1 -1
  101. package/lib/esm/runtime/lookup-css.d.ts +11 -0
  102. package/lib/esm/runtime/lookup-css.mjs +14 -1
  103. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  104. package/lib/esm/runtime/resolve.mjs +9 -7
  105. package/lib/esm/runtime/resolve.mjs.map +1 -1
  106. package/lib/esm/runtime/wrap.d.ts +10 -4
  107. package/lib/esm/runtime/wrap.mjs +50 -57
  108. package/lib/esm/runtime/wrap.mjs.map +1 -1
  109. package/package.json +1 -1
  110. package/src/core/parser/color.ts +32 -1
  111. package/src/core/parser/declaration.ts +160 -10
  112. package/src/core/parser/gradient.ts +48 -11
  113. package/src/core/parser/keyframes.ts +31 -3
  114. package/src/core/parser/layout-dispatcher.ts +32 -9
  115. package/src/core/parser/length.ts +18 -1
  116. package/src/core/parser/safe-area.ts +23 -2
  117. package/src/core/parser/theme-vars.ts +75 -8
  118. package/src/core/parser/tokens.ts +76 -9
  119. package/src/core/parser/transform.ts +19 -8
  120. package/src/core/parser/tw-parser.ts +95 -30
  121. package/src/core/parser/typography-dispatcher.ts +20 -1
  122. package/src/core/parser/typography.ts +15 -15
  123. package/src/core/style-builder/union-builder.ts +0 -11
  124. package/src/metro/dts.ts +6 -1
  125. package/src/metro/transformer.ts +45 -61
  126. package/src/metro/with-config.ts +10 -29
  127. package/src/runtime/hooks/use-scheme.ts +9 -6
  128. package/src/runtime/index.ts +1 -1
  129. package/src/runtime/lookup-css.ts +14 -0
  130. package/src/runtime/resolve.ts +9 -7
  131. package/src/runtime/wrap.tsx +57 -61
@@ -3,25 +3,25 @@ import { dimensionPercentageToNumber } from './length'
3
3
  import type { RNEntry } from './types'
4
4
 
5
5
  /**
6
- * Expand lightningcss's `Display` typed value to an RN `{display}` entry.
7
- * - `keyword` variant (`none`, `flex`, `grid`, `inline`, …) passes through.
8
- * - `pair` variant (the modern CSS model `{inside: {type}, outside,
9
- * isListItem}`) collapses to RN's `'flex'` / `'grid'` when the inside
10
- * type matches, otherwise skips.
6
+ * Display values React Native's `display` style prop actually accepts.
7
+ * Everything else (`block`, `inline`, `inline-block`, `grid`, `table`, …)
8
+ * has no RN analog RN lays out as flex by default, and emitting an invalid
9
+ * value triggers a dev warning + silent drop. So we drop them outright.
10
+ */
11
+ const RN_DISPLAY_VALUES: ReadonlySet<string> = new Set(['none', 'flex', 'contents'])
12
+
13
+ /**
14
+ * Expand lightningcss's `Display` typed value to an RN `{display}` entry,
15
+ * keeping only the values RN supports (`none` / `flex` / `contents`).
16
+ * - `keyword` variant emits only when the keyword is RN-valid.
17
+ * - `pair` variant (the modern CSS model) collapses `flex` inside to
18
+ * `'flex'`; `flow` (`block`/`inline`) and `grid` have no RN analog → drop.
11
19
  * @param value Typed display value.
12
20
  * @returns RN entries (zero or one).
13
21
  */
14
22
  export function displayToEntries(value: Display): readonly RNEntry[] {
15
- if (value.type === 'keyword') return [['display', value.value]]
16
- if (value.type === 'pair') {
17
- const inside = value.inside.type
18
- // `flow` is the default inside mode — maps to `block` / `inline` /
19
- // `inline-block` based on the outer; RN only distinguishes `block`-ish
20
- // from `flex`, so collapse the `flow` family to the `outside` keyword.
21
- if (inside === 'flow') return [['display', value.outside]]
22
- if (inside === 'flex') return [['display', 'flex']]
23
- if (inside === 'grid') return [['display', 'grid']]
24
- }
23
+ if (value.type === 'keyword') return RN_DISPLAY_VALUES.has(value.value) ? [['display', value.value]] : []
24
+ if (value.type === 'pair' && value.inside.type === 'flex') return [['display', 'flex']]
25
25
  return []
26
26
  }
27
27
 
@@ -143,17 +143,6 @@ class UnionBuilder {
143
143
  return path.join(this.cacheDir, MANIFEST_BASENAME)
144
144
  }
145
145
 
146
- /**
147
- * Snapshot of every source file the builder has recorded atoms for
148
- * this worker session. Used by `withRnwindConfig`'s CSS watcher to
149
- * touch `mtime` on each and nudge Metro into re-transforming them
150
- * with the new theme values.
151
- * @returns Absolute source paths.
152
- */
153
- public recordedFiles(): readonly string[] {
154
- return [...this.fileAtomSets.keys()]
155
- }
156
-
157
146
  /** Cumulative cache-miss count — exposed for tests to assert cache behaviour. */
158
147
  public get serializedMisses(): number {
159
148
  return this.serializedMissesCount
package/src/metro/dts.ts CHANGED
@@ -100,7 +100,12 @@ export function writeDtsFile(targetPath: string, schemes: readonly string[]): vo
100
100
  lines.push('}', '')
101
101
  if (schemes.length > 0) {
102
102
  lines.push(`declare module 'rnwind' {`, ` export interface RnwindConfig {`)
103
- const schemeLiterals = schemes.map((s) => `'${s}'`).join(', ')
103
+ // Escape backslash / single-quote so a scheme name with a quote (only
104
+ // reachable via the public `writeDtsFile`, since CSS idents can't contain
105
+ // one) can't emit invalid TS that breaks the whole file and drops the
106
+ // `className` augmentation project-wide. Single-quoted to match the rest
107
+ // of the generated declaration.
108
+ const schemeLiterals = schemes.map((s) => `'${s.replaceAll('\\', '\\\\').replaceAll("'", String.raw`\'`)}'`).join(', ')
104
109
  lines.push(` themes: readonly [${schemeLiterals}]`, ` }`, '}', '')
105
110
  }
106
111
  // The `export {}` is mandatory — without at least one top-level
@@ -137,35 +137,53 @@ function isThemeCssEntry(filename: string): boolean {
137
137
  * @returns Rewritten source text.
138
138
  */
139
139
  async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
140
- const ast = parseUserSource(args.src)
141
- if (!ast) return args.src
142
-
143
- // Wrap host component imports so `<View className=…>` resolves at render
144
- // through the runtime `wrap` (works for literal, spread, and forwarded
145
- // classNames alike). No JSX is rewritten here.
146
- const wrapped = rewriteWrapImports(ast, getWrapModules())
147
-
148
- if (!/classname=/i.test(args.src)) {
149
- // Import-only file: nothing to compile. Drop any stale atom
150
- // contribution (className may have just been removed) and emit the
151
- // wrapped imports — or the untouched source when nothing wrapped.
152
- dropFileSafely(args.filename, projectRootOf(args))
153
- return wrapped ? generateModule(ast).code : args.src
154
- }
155
-
140
+ // SCAN FIRST — exactly like Tailwind: oxide extracts class candidates from
141
+ // the WHOLE file content (className, cva/clsx, plain strings, anywhere),
142
+ // and the compiler is the only filter. No babel parse needed to scan, so
143
+ // the common "file with no classes" case stays cheap and the source is
144
+ // returned untouched. This is what makes adding a class ANYWHERE register
145
+ // on hot-reload, not just inside a `className=` or a known helper call.
156
146
  const state = getRnwindState(projectRootOf(args))
157
147
  const extension = extensionOf(args.filename)
158
148
  const parsed = await state.parser.parseAtoms({ content: args.src, extension })
149
+ const hasAtoms = parsed.atoms.size > 0
159
150
 
160
- warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename, [parsed.gradientAtoms, parsed.hapticAtoms])
151
+ const hasClassName = /classname=/i.test(args.src)
152
+ const hasSpread = /\{\s*\.\.\./.test(args.src)
161
153
 
162
- if (parsed.atoms.size === 0) {
154
+ // Nothing for rnwind to do: no Tailwind class compiled AND no host
155
+ // wrapping needed. Drop any stale contribution and emit the file as-is.
156
+ if (!hasAtoms && !hasClassName && !hasSpread) {
163
157
  state.builder.dropFile(args.filename)
164
- await state.builder.writeSchemes()
165
- injectThemeSignatureImport(ast)
166
- return generateModule(ast).code
158
+ return args.src
159
+ }
160
+
161
+ const ast = parseUserSource(args.src)
162
+ if (!ast) {
163
+ // Can't parse to wrap/inject, but we DID scan — still record the atoms so
164
+ // they register (a malformed-but-recoverable file shouldn't lose styles).
165
+ if (hasAtoms) {
166
+ await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes, [])
167
+ await state.builder.writeSchemes()
168
+ } else {
169
+ state.builder.dropFile(args.filename)
170
+ }
171
+ return args.src
172
+ }
173
+
174
+ // Wrap host imports ONLY when className flows through a component — written
175
+ // (`hasClassName`) or forwarded (`{...rest}`). A `useCss`/`cva`-only file
176
+ // resolves manually, so its imports (e.g. skia drawing primitives) are
177
+ // left untouched.
178
+ const wrapped = hasClassName || hasSpread ? rewriteWrapImports(ast, getWrapModules()) : false
179
+
180
+ if (!hasAtoms) {
181
+ // Wrap-only forwarder — no classes to record/register.
182
+ state.builder.dropFile(args.filename)
183
+ return wrapped ? generateModule(ast).code : args.src
167
184
  }
168
185
 
186
+ warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename, [parsed.gradientAtoms, parsed.hapticAtoms])
169
187
  const literals = collectClassNameLiterals(ast)
170
188
  const { changed } = await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes, literals)
171
189
  if (changed) await state.builder.writeSchemes()
@@ -175,21 +193,6 @@ async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
175
193
  return generateModule(ast).code
176
194
  }
177
195
 
178
- /**
179
- * Drop a file's union contribution, swallowing the "state not configured"
180
- * error unit tests hit when they call the transformer without
181
- * `configureRnwindState`.
182
- * @param filename Absolute source path.
183
- * @param projectRoot Project root for state lookup.
184
- */
185
- function dropFileSafely(filename: string, projectRoot: string): void {
186
- try {
187
- getRnwindState(projectRoot).builder.dropFile(filename)
188
- } catch {
189
- // State not configured (standalone/unit test). Nothing to drop.
190
- }
191
- }
192
-
193
196
  /**
194
197
  * Whether a JSX attribute names a className-style prop (`className` or
195
198
  * any `<prefix>ClassName`).
@@ -387,16 +390,12 @@ function loadUpstream(): UpstreamTransformer | null {
387
390
  */
388
391
  function isRewriteCandidate(args: BabelTransformerArgs): boolean {
389
392
  if (!/\.(?:tsx|ts|jsx|js)$/i.test(args.filename)) return false
390
- // Process the file when it either:
391
- // - carries a `className=` / `<prefix>ClassName=` literal (case-
392
- // insensitive `contentContainerClassName=` has a capital C), or
393
- // - spreads props (`{...rest}`) onto a host from a wrap-module, where a
394
- // forwarded className must still get its import wrapped (no literal
395
- // appears in this file). A style-less `<View/>` with neither is left
396
- // alone so it never pays for an unused wrapper.
397
- const hasClassName = /classname=/i.test(args.src)
398
- const isForwarder = /\{\s*\.\.\./.test(args.src) && mentionsWrapModule(args.src)
399
- if (!hasClassName && !isForwarder) return false
393
+ // EVERY user source file is scanned — exactly like Tailwind walks its
394
+ // whole content set. The compiler is the only filter (see rewriteSource);
395
+ // no className/helper pre-filter, because that "filter magic" missed
396
+ // classes living in cva/clsx/plain-string files and broke their hot-reload.
397
+ // (Cheap when the file has no classes: oxide finds nothing, no babel parse,
398
+ // source returned untouched.)
400
399
  if (!args.filename.includes('/node_modules/')) return true
401
400
  // node_modules in path → could be a workspace symlink; resolve it.
402
401
  try {
@@ -407,21 +406,6 @@ function isRewriteCandidate(args: BabelTransformerArgs): boolean {
407
406
  }
408
407
  }
409
408
 
410
- /**
411
- * Cheap pre-parse check: does the source import from any configured
412
- * wrap-module? A quoted specifier match is enough — `rewriteWrapImports`
413
- * re-verifies precisely on the AST, so a false positive only costs a
414
- * no-op parse.
415
- * @param source Source text.
416
- * @returns True when a wrap-module specifier appears in the source.
417
- */
418
- function mentionsWrapModule(source: string): boolean {
419
- for (const moduleName of getWrapModules().keys()) {
420
- if (source.includes(`'${moduleName}'`) || source.includes(`"${moduleName}"`)) return true
421
- }
422
- return false
423
- }
424
-
425
409
  /**
426
410
  * Fallback parse when no upstream is configured AND Metro didn't hand
427
411
  * us an AST. Used by unit tests and standalone setups.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, utimesSync, watch as watchFile } from 'node:fs'
1
+ import { existsSync, mkdirSync, 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'
@@ -15,14 +15,15 @@ const DEFAULT_CACHE_DIR = '.rnwind'
15
15
  let activeCssWatcher: { cssPath: string; close: () => void } | null = null
16
16
 
17
17
  /**
18
- * Watch the theme CSS for edits. On change, rewrite the per-scheme
19
- * files with the fresh theme AND bump `mtime` on every source file
20
- * rnwind has transformed so far Metro's own watcher sees those
21
- * mtime changes, invalidates the modules, and re-transforms them
22
- * against the new CSS on the next request. `getCacheKey()` alone is
23
- * NOT enough: Metro samples the cache key once per worker lifetime,
24
- * so edits during an already-running dev server don't propagate
25
- * without this explicit nudge.
18
+ * Watch the theme CSS for edits. On change, rebuild state against the fresh
19
+ * CSS and rewrite the per-scheme files (`onThemeChange` full rescan
20
+ * `writeSchemes`). That's the entire HMR signal: every transformed source
21
+ * imports `rnwind/__generated/schemes`, which eager-imports
22
+ * `common.style.js`; the rewrite changes its bytes, Metro's content-SHA1
23
+ * dedup notices, and every importer is invalidated + re-bundled with the new
24
+ * style values. The rewritten JSX references atoms by NAME (theme-independent),
25
+ * so no source-file re-transform / mtime nudge is needed — the dep graph
26
+ * carries the signal.
26
27
  * @param cssPath Absolute path to the theme CSS to watch.
27
28
  * @param projectRoot Metro's project root (for `getRnwindState`).
28
29
  */
@@ -40,7 +41,6 @@ function watchThemeCss(cssPath: string, projectRoot: string): void {
40
41
  pending = false
41
42
  try {
42
43
  await onThemeChange(projectRoot)
43
- touchRecordedFiles(projectRoot)
44
44
  } catch {
45
45
  // Invalidation is best-effort — never crash the dev server.
46
46
  }
@@ -49,25 +49,6 @@ function watchThemeCss(cssPath: string, projectRoot: string): void {
49
49
  activeCssWatcher = { cssPath, close: () => watcher.close() }
50
50
  }
51
51
 
52
- /**
53
- * Bump the mtime on every file the builder has transformed. Metro's
54
- * file watcher keys on `mtime`, so this is what makes it invalidate
55
- * those modules and re-transform them.
56
- * @param projectRoot Metro's project root.
57
- */
58
- function touchRecordedFiles(projectRoot: string): void {
59
- const state = getRnwindState(projectRoot)
60
- const files = state.builder.recordedFiles()
61
- const now = new Date()
62
- for (const file of files) {
63
- try {
64
- if (existsSync(file)) utimesSync(file, now, now)
65
- } catch {
66
- // One file's stat failure shouldn't stop the others.
67
- }
68
- }
69
- }
70
-
71
52
  /**
72
53
  * Where the rnwind babel transformer lives — resolved relative to this
73
54
  * module so the path works from both the `src/` tree (tests) and the
@@ -42,22 +42,25 @@ export function useToken(cssVariable: string): string | number | undefined {
42
42
 
43
43
  /**
44
44
  * Read a color token by shorthand name — `useColor('primary')` resolves
45
- * `--color-primary` for the active scheme.
46
- * @param name Token suffix after `--color-`.
45
+ * `--color-primary` for the active scheme. A fully-qualified name
46
+ * (`--color-primary`) is accepted as-is, so the call doesn't silently miss by
47
+ * double-prefixing into `--color---color-primary`.
48
+ * @param name Token suffix after `--color-`, or the full `--color-*` name.
47
49
  * @returns Resolved color string, or undefined when the token is missing
48
50
  * or its value isn't a string.
49
51
  */
50
52
  export function useColor(name: string): string | undefined {
51
- const value = useToken(`--color-${name}`)
53
+ const value = useToken(name.startsWith('--') ? name : `--color-${name}`)
52
54
  return typeof value === 'string' ? value : undefined
53
55
  }
54
56
 
55
57
  /**
56
58
  * Read a spacing token by shorthand name — `useSize('4')` resolves
57
- * `--spacing-4` for the active scheme.
58
- * @param name Token suffix after `--spacing-`.
59
+ * `--spacing-4` for the active scheme. A fully-qualified `--spacing-*` name is
60
+ * accepted as-is (no double-prefix miss).
61
+ * @param name Token suffix after `--spacing-`, or the full `--spacing-*` name.
59
62
  * @returns Resolved spacing value, or undefined when the token is missing.
60
63
  */
61
64
  export function useSize(name: string): number | string | undefined {
62
- return useToken(`--spacing-${name}`)
65
+ return useToken(name.startsWith('--') ? name : `--spacing-${name}`)
63
66
  }
@@ -28,4 +28,4 @@ export type { Scheme, RnwindConfig } from './types'
28
28
  * string when integrating with build tooling that may see multiple rnwind
29
29
  * copies (e.g. workspace overrides).
30
30
  */
31
- export const VERSION = '0.0.1' as const
31
+ export const VERSION = '0.0.8' as const
@@ -280,6 +280,20 @@ function tierFor(windowWidth: number): number {
280
280
  return tier
281
281
  }
282
282
 
283
+ /**
284
+ * Public breakpoint-tier for a width — the count of registered breakpoints
285
+ * whose threshold is reached. Used by the runtime resolver as its width
286
+ * cache dimension: the numeric tier is bounded AND exact, unlike the
287
+ * clamped `activeBreakpoint` NAME (which collapses tier-0 into the smallest
288
+ * breakpoint, so widths straddling that threshold would share a cache key
289
+ * and serve a stale style).
290
+ * @param windowWidth Live window width in px.
291
+ * @returns Tier 0..N.
292
+ */
293
+ export function breakpointTier(windowWidth: number): number {
294
+ return tierFor(windowWidth)
295
+ }
296
+
283
297
  /**
284
298
  * Build the style array for a (hoist, scheme, state, width) tuple.
285
299
  * Walks the atom list, applies the interact-state and breakpoint
@@ -1,4 +1,4 @@
1
- import { getStyleVersion, lookupCss, type InteractState } from './lookup-css'
1
+ import { breakpointTier, getStyleVersion, lookupCss, type InteractState } from './lookup-css'
2
2
  import type { RnwindState } from './components/rnwind-provider'
3
3
  import { normalizeClassName } from '../core/normalize-classname'
4
4
  import type { GradientAtomInfo, GradientDirection } from '../core/parser/gradient'
@@ -148,17 +148,19 @@ const stateSignatureCache = new WeakMap<RnwindState, string>()
148
148
 
149
149
  /**
150
150
  * Cache key dimension for the reactive context — everything that can
151
- * change a resolved style. Uses the breakpoint TIER (`activeBreakpoint`),
152
- * NOT the raw `windowWidth`: two widths in the same tier gate `md:*` atoms
153
- * identically, so they resolve the same. This collapses the window axis
154
- * from "every pixel" to ~6 values, bounding the cache on resizable
155
- * surfaces (web / desktop) without changing any result.
151
+ * change a resolved style. Uses the numeric breakpoint TIER (count of
152
+ * thresholds reached) from `breakpointTier(windowWidth)`, NOT the
153
+ * `activeBreakpoint` NAME: the name clamps tier-0 into the smallest
154
+ * breakpoint, so widths straddling that threshold (e.g. 320 vs 700 with
155
+ * `sm=640`) would collide on one cache key and serve a stale style. The
156
+ * tier is exact AND bounded — two widths in the same tier gate every
157
+ * `sm:`/`md:`/… atom identically, so they resolve the same.
156
158
  * @param state Rnwind context.
157
159
  * @returns Compact signature string.
158
160
  */
159
161
  function stateSignature(state: RnwindState): string {
160
162
  const { insets } = state
161
- return `${state.scheme}|${insets.top},${insets.right},${insets.bottom},${insets.left}|${state.fontScale}|${state.activeBreakpoint}`
163
+ return `${state.scheme}|${insets.top},${insets.right},${insets.bottom},${insets.left}|${state.fontScale}|${breakpointTier(state.windowWidth)}`
162
164
  }
163
165
 
164
166
  /**
@@ -3,6 +3,7 @@ import { chainFocus, chainPress } from './chain-handlers'
3
3
  import { useInteract } from './hooks/use-interact'
4
4
  import { useRnwind } from './components/rnwind-provider'
5
5
  import type { RnwindState } from './components/rnwind-provider'
6
+ import type { InteractState } from './lookup-css'
6
7
  import { resolve, type ResolvedCss } from './resolve'
7
8
  import type { OnHaptics } from '../core/parser/haptics'
8
9
 
@@ -79,14 +80,20 @@ const CLASSNAME_SUFFIX = 'ClassName'
79
80
  * handled separately by the leaf and never reaches here.
80
81
  * @param props Mutable prop object being assembled for the host.
81
82
  * @param state Rnwind context for resolution.
83
+ * @param interactState Live press/focus state so `active:`/`focus:` variants
84
+ * on a secondary class prop resolve too (undefined for non-interactive).
82
85
  */
83
- function applyContainerClassNames(props: Record<string, unknown>, state: RnwindState): void {
86
+ function applyContainerClassNames(
87
+ props: Record<string, unknown>,
88
+ state: RnwindState,
89
+ interactState?: InteractState,
90
+ ): void {
84
91
  for (const key of Object.keys(props)) {
85
92
  if (!key.endsWith(CLASSNAME_SUFFIX)) continue
86
93
  const value = props[key]
87
94
  if (typeof value !== 'string') continue
88
95
  const styleKey = `${key.slice(0, -CLASSNAME_SUFFIX.length)}Style`
89
- props[styleKey] = resolve(value, state, props[styleKey]).style
96
+ props[styleKey] = resolve(value, state, props[styleKey], interactState).style
90
97
  delete props[key]
91
98
  }
92
99
  }
@@ -102,6 +109,7 @@ function applyContainerClassNames(props: Record<string, unknown>, state: RnwindS
102
109
  * @param state Rnwind context — used to resolve secondary class props.
103
110
  * @param onHaptics Dispatcher from context.
104
111
  * @param userOnPressIn Caller-supplied onPressIn to chain after the haptic.
112
+ * @param interactState Live press/focus state for secondary class props.
105
113
  * @returns The merged prop object for `createElement`.
106
114
  */
107
115
  function buildProps(
@@ -110,10 +118,11 @@ function buildProps(
110
118
  state: RnwindState,
111
119
  onHaptics: OnHaptics | undefined,
112
120
  userOnPressIn?: unknown,
121
+ interactState?: InteractState,
113
122
  ): Record<string, unknown> {
114
123
  warnIfHapticsUnwired(onHaptics, resolved.haptics)
115
124
  const props: Record<string, unknown> = { ...rest, style: resolved.style }
116
- applyContainerClassNames(props, state)
125
+ applyContainerClassNames(props, state, interactState)
117
126
  if (resolved.colors) {
118
127
  props.colors = resolved.colors
119
128
  props.start = resolved.start
@@ -136,66 +145,31 @@ function buildProps(
136
145
  return props
137
146
  }
138
147
 
139
- /** Props a leaf receivesthe wrapped `as` tag plus forwarded props. */
140
- interface LeafProps {
141
- readonly as: ComponentType<Record<string, unknown>>
148
+ /** Props the wrapped renderer accepts — `className` plus forwarded props. */
149
+ interface WrappedProps {
142
150
  readonly className?: string
143
151
  readonly style?: unknown
152
+ readonly onPressIn?: unknown
153
+ readonly onPressOut?: unknown
154
+ readonly onFocus?: unknown
155
+ readonly onBlur?: unknown
144
156
  readonly [key: string]: unknown
145
157
  }
146
158
 
147
- /**
148
- * Non-interactive leaf: resolve className → style (+ features) and
149
- * forward. One context read, one molecule/atom resolve.
150
- * @param props Leaf props.
151
- * @param props.as
152
- * @param props.className
153
- * @param props.style
154
- * @param props.onPressIn
155
- * @returns The rendered `as` element.
156
- */
157
- function PlainLeaf({ as: As, className, style, onPressIn, ...rest }: LeafProps): ReactElement {
158
- const state = useRnwind()
159
- const resolved = resolve(className, state, style)
160
- useMountHaptics(resolved, state.onHaptics)
161
- return createElement(As, buildProps(rest, resolved, state, state.onHaptics, onPressIn))
162
- }
163
-
164
- /**
165
- * Interactive leaf: tracks press/focus via `useInteract()`, feeds it into
166
- * `resolve` so `active:`/`focus:` atoms apply, and chains the
167
- * press/focus handlers.
168
- * @param props Leaf props.
169
- * @param props.as
170
- * @param props.className
171
- * @param props.style
172
- * @param props.onPressIn
173
- * @param props.onPressOut
174
- * @param props.onFocus
175
- * @param props.onBlur
176
- * @returns The rendered `as` element with interactive wiring.
177
- */
178
- function InteractiveLeaf({ as: As, className, style, onPressIn, onPressOut, onFocus, onBlur, ...rest }: LeafProps): ReactElement {
179
- const state = useRnwind()
180
- const interact = useInteract()
181
- const resolved = resolve(className, state, style, interact.state)
182
- useMountHaptics(resolved, state.onHaptics)
183
- const props = buildProps(rest, resolved, state, state.onHaptics, onPressIn)
184
- props.onPressIn = chainPress(props.onPressIn as Parameters<typeof chainPress>[0], interact.onPressIn)
185
- props.onPressOut = chainPress(onPressOut as Parameters<typeof chainPress>[0], interact.onPressOut)
186
- props.onFocus = chainFocus(onFocus as Parameters<typeof chainFocus>[0], interact.onFocus)
187
- props.onBlur = chainFocus(onBlur as Parameters<typeof chainFocus>[0], interact.onBlur)
188
- return createElement(As, props)
189
- }
190
-
191
159
  /**
192
160
  * Wrap a component so its `className` prop resolves to RN `style` (plus
193
161
  * gradient / truncate props and haptic dispatch) at render — no matter
194
162
  * how className arrived: written directly, spread through `{...rest}`, or
195
- * forwarded down custom wrappers. The returned component is hook-free; it
196
- * dispatches to a plain or interactive leaf so non-interactive elements
197
- * never pay for press/focus state. `ref` (a normal prop in React 19) and
198
- * all other props forward untouched.
163
+ * forwarded down custom wrappers. `ref` (a normal prop in React 19) and all
164
+ * other props forward untouched.
165
+ *
166
+ * A SINGLE stable component does the work and always calls `useInteract()`,
167
+ * so its identity never changes when `className` flips between interactive
168
+ * (`active:`/`focus:`) and not — swapping the rendered component type would
169
+ * unmount + remount the whole host subtree (lost state, effect re-runs,
170
+ * visual flash). `useInteract` is cheap and shares one idle-state ref, so the
171
+ * always-on cost is a couple of `useState` cells; the press/focus handlers
172
+ * and live state only wire in when the className actually needs them.
199
173
  * @example
200
174
  * ```tsx
201
175
  * const Pressable = wrap(RNPressable)
@@ -207,16 +181,38 @@ function InteractiveLeaf({ as: As, className, style, onPressIn, onPressOut, onFo
207
181
  export function wrap<P>(Component: ComponentType<P>): ComponentType<P & { className?: string }> {
208
182
  const as = Component as unknown as ComponentType<Record<string, unknown>>
209
183
  /**
210
- * The wrapped component hook-free dispatcher to a leaf.
184
+ * The wrapped component. Stable identity + unconditional hooks; branches
185
+ * internally on whether the className carries an interactive variant.
211
186
  * @param props Forwarded props with `className` intercepted.
212
- * @param props.className
213
- * @returns The rendered leaf.
187
+ * @param props.className Raw className string.
188
+ * @param props.style Caller-supplied style, merged under the resolved style.
189
+ * @param props.onPressIn Caller onPressIn — chained after haptics / interact.
190
+ * @param props.onPressOut Caller onPressOut — chained with interact when active.
191
+ * @param props.onFocus Caller onFocus — chained with interact when active.
192
+ * @param props.onBlur Caller onBlur — chained with interact when active.
193
+ * @returns The rendered `as` element.
214
194
  */
215
- function RnwindWrapped({ className, ...rest }: { className?: string; [key: string]: unknown }): ReactElement {
216
- if (className !== undefined && hasInteractiveVariant(className)) {
217
- return createElement(InteractiveLeaf, { as, className, ...rest })
195
+ function RnwindWrapped({ className, style, onPressIn, onPressOut, onFocus, onBlur, ...rest }: WrappedProps): ReactElement {
196
+ const state = useRnwind()
197
+ const interact = useInteract()
198
+ const isInteractive = className !== undefined && hasInteractiveVariant(className)
199
+ const interactState = isInteractive ? interact.state : undefined
200
+ const resolved = resolve(className, state, style, interactState)
201
+ useMountHaptics(resolved, state.onHaptics)
202
+ const props = buildProps(rest, resolved, state, state.onHaptics, onPressIn, interactState)
203
+ if (isInteractive) {
204
+ props.onPressIn = chainPress(props.onPressIn as Parameters<typeof chainPress>[0], interact.onPressIn)
205
+ props.onPressOut = chainPress(onPressOut as Parameters<typeof chainPress>[0], interact.onPressOut)
206
+ props.onFocus = chainFocus(onFocus as Parameters<typeof chainFocus>[0], interact.onFocus)
207
+ props.onBlur = chainFocus(onBlur as Parameters<typeof chainFocus>[0], interact.onBlur)
208
+ } else {
209
+ // Forward the caller's press/focus handlers untouched (onPressIn is
210
+ // already set by buildProps, possibly haptic-chained).
211
+ if (onPressOut !== undefined) props.onPressOut = onPressOut
212
+ if (onFocus !== undefined) props.onFocus = onFocus
213
+ if (onBlur !== undefined) props.onBlur = onBlur
218
214
  }
219
- return createElement(PlainLeaf, { as, className, ...rest })
215
+ return createElement(as, props)
220
216
  }
221
217
  RnwindWrapped.displayName = `wrap(${displayNameOf(Component)})`
222
218
  return RnwindWrapped as unknown as ComponentType<P & { className?: string }>