rnwind 0.0.3 → 0.0.5

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 (134) hide show
  1. package/lib/cjs/core/normalize-classname.cjs +25 -0
  2. package/lib/cjs/core/normalize-classname.cjs.map +1 -0
  3. package/lib/cjs/core/normalize-classname.d.ts +10 -0
  4. package/lib/cjs/core/style-builder/build-style.cjs +258 -58
  5. package/lib/cjs/core/style-builder/build-style.cjs.map +1 -1
  6. package/lib/cjs/core/style-builder/build-style.d.ts +6 -1
  7. package/lib/cjs/core/style-builder/union-builder.cjs +37 -3
  8. package/lib/cjs/core/style-builder/union-builder.cjs.map +1 -1
  9. package/lib/cjs/core/style-builder/union-builder.d.ts +21 -1
  10. package/lib/cjs/metro/css-imports.cjs +81 -0
  11. package/lib/cjs/metro/css-imports.cjs.map +1 -0
  12. package/lib/cjs/metro/css-imports.d.ts +8 -0
  13. package/lib/cjs/metro/dts.cjs +7 -16
  14. package/lib/cjs/metro/dts.cjs.map +1 -1
  15. package/lib/cjs/metro/dts.d.ts +2 -4
  16. package/lib/cjs/metro/state.cjs +38 -86
  17. package/lib/cjs/metro/state.cjs.map +1 -1
  18. package/lib/cjs/metro/state.d.ts +8 -25
  19. package/lib/cjs/metro/transformer.cjs +193 -34
  20. package/lib/cjs/metro/transformer.cjs.map +1 -1
  21. package/lib/cjs/metro/with-config.cjs +2 -2
  22. package/lib/cjs/metro/with-config.cjs.map +1 -1
  23. package/lib/cjs/metro/with-config.d.ts +11 -26
  24. package/lib/cjs/metro/wrap-imports.cjs +273 -0
  25. package/lib/cjs/metro/wrap-imports.cjs.map +1 -0
  26. package/lib/cjs/metro/wrap-imports.d.ts +26 -0
  27. package/lib/cjs/runtime/components/rnwind-provider.cjs +0 -17
  28. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -1
  29. package/lib/cjs/runtime/components/rnwind-provider.d.ts +0 -14
  30. package/lib/cjs/runtime/hooks/use-css.cjs +16 -10
  31. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -1
  32. package/lib/cjs/runtime/hooks/use-css.d.ts +15 -9
  33. package/lib/cjs/runtime/index.cjs +11 -13
  34. package/lib/cjs/runtime/index.cjs.map +1 -1
  35. package/lib/cjs/runtime/index.d.ts +4 -9
  36. package/lib/cjs/runtime/lookup-css.cjs +10 -0
  37. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  38. package/lib/cjs/runtime/lookup-css.d.ts +7 -0
  39. package/lib/cjs/runtime/resolve.cjs +348 -0
  40. package/lib/cjs/runtime/resolve.cjs.map +1 -0
  41. package/lib/cjs/runtime/resolve.d.ts +61 -0
  42. package/lib/cjs/runtime/wrap.cjs +254 -0
  43. package/lib/cjs/runtime/wrap.cjs.map +1 -0
  44. package/lib/cjs/runtime/wrap.d.ts +37 -0
  45. package/lib/cjs/testing/index.cjs +81 -50
  46. package/lib/cjs/testing/index.cjs.map +1 -1
  47. package/lib/esm/core/normalize-classname.d.ts +10 -0
  48. package/lib/esm/core/normalize-classname.mjs +23 -0
  49. package/lib/esm/core/normalize-classname.mjs.map +1 -0
  50. package/lib/esm/core/style-builder/build-style.d.ts +6 -1
  51. package/lib/esm/core/style-builder/build-style.mjs +258 -58
  52. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  53. package/lib/esm/core/style-builder/union-builder.d.ts +21 -1
  54. package/lib/esm/core/style-builder/union-builder.mjs +37 -3
  55. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  56. package/lib/esm/metro/css-imports.d.ts +8 -0
  57. package/lib/esm/metro/css-imports.mjs +79 -0
  58. package/lib/esm/metro/css-imports.mjs.map +1 -0
  59. package/lib/esm/metro/dts.d.ts +2 -4
  60. package/lib/esm/metro/dts.mjs +7 -16
  61. package/lib/esm/metro/dts.mjs.map +1 -1
  62. package/lib/esm/metro/state.d.ts +8 -25
  63. package/lib/esm/metro/state.mjs +39 -85
  64. package/lib/esm/metro/state.mjs.map +1 -1
  65. package/lib/esm/metro/transformer.mjs +194 -35
  66. package/lib/esm/metro/transformer.mjs.map +1 -1
  67. package/lib/esm/metro/with-config.d.ts +11 -26
  68. package/lib/esm/metro/with-config.mjs +2 -2
  69. package/lib/esm/metro/with-config.mjs.map +1 -1
  70. package/lib/esm/metro/wrap-imports.d.ts +26 -0
  71. package/lib/esm/metro/wrap-imports.mjs +250 -0
  72. package/lib/esm/metro/wrap-imports.mjs.map +1 -0
  73. package/lib/esm/runtime/components/rnwind-provider.d.ts +0 -14
  74. package/lib/esm/runtime/components/rnwind-provider.mjs +1 -17
  75. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -1
  76. package/lib/esm/runtime/hooks/use-css.d.ts +15 -9
  77. package/lib/esm/runtime/hooks/use-css.mjs +16 -10
  78. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -1
  79. package/lib/esm/runtime/index.d.ts +4 -9
  80. package/lib/esm/runtime/index.mjs +4 -4
  81. package/lib/esm/runtime/index.mjs.map +1 -1
  82. package/lib/esm/runtime/lookup-css.d.ts +7 -0
  83. package/lib/esm/runtime/lookup-css.mjs +10 -1
  84. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  85. package/lib/esm/runtime/resolve.d.ts +61 -0
  86. package/lib/esm/runtime/resolve.mjs +341 -0
  87. package/lib/esm/runtime/resolve.mjs.map +1 -0
  88. package/lib/esm/runtime/wrap.d.ts +37 -0
  89. package/lib/esm/runtime/wrap.mjs +251 -0
  90. package/lib/esm/runtime/wrap.mjs.map +1 -0
  91. package/lib/esm/testing/index.mjs +84 -53
  92. package/lib/esm/testing/index.mjs.map +1 -1
  93. package/package.json +2 -1
  94. package/src/core/normalize-classname.ts +19 -0
  95. package/src/core/style-builder/build-style.ts +286 -55
  96. package/src/core/style-builder/union-builder.ts +36 -3
  97. package/src/metro/css-imports.ts +75 -0
  98. package/src/metro/dts.ts +7 -19
  99. package/src/metro/state.ts +38 -83
  100. package/src/metro/transformer.ts +190 -34
  101. package/src/metro/with-config.ts +13 -28
  102. package/src/metro/wrap-imports.ts +260 -0
  103. package/src/runtime/components/rnwind-provider.tsx +0 -17
  104. package/src/runtime/hooks/use-css.ts +17 -11
  105. package/src/runtime/index.ts +3 -26
  106. package/src/runtime/lookup-css.ts +10 -0
  107. package/src/runtime/resolve.ts +381 -0
  108. package/src/runtime/wrap.tsx +267 -0
  109. package/src/testing/index.ts +106 -56
  110. package/lib/cjs/core/parser/text-truncate.cjs +0 -78
  111. package/lib/cjs/core/parser/text-truncate.cjs.map +0 -1
  112. package/lib/cjs/metro/transform-ast.cjs +0 -1472
  113. package/lib/cjs/metro/transform-ast.cjs.map +0 -1
  114. package/lib/cjs/metro/transform-ast.d.ts +0 -88
  115. package/lib/cjs/runtime/haptics.cjs +0 -113
  116. package/lib/cjs/runtime/haptics.cjs.map +0 -1
  117. package/lib/cjs/runtime/haptics.d.ts +0 -48
  118. package/lib/cjs/runtime/interactive-box.cjs +0 -35
  119. package/lib/cjs/runtime/interactive-box.cjs.map +0 -1
  120. package/lib/cjs/runtime/interactive-box.d.ts +0 -40
  121. package/lib/esm/core/parser/text-truncate.mjs +0 -75
  122. package/lib/esm/core/parser/text-truncate.mjs.map +0 -1
  123. package/lib/esm/metro/transform-ast.d.ts +0 -88
  124. package/lib/esm/metro/transform-ast.mjs +0 -1451
  125. package/lib/esm/metro/transform-ast.mjs.map +0 -1
  126. package/lib/esm/runtime/haptics.d.ts +0 -48
  127. package/lib/esm/runtime/haptics.mjs +0 -110
  128. package/lib/esm/runtime/haptics.mjs.map +0 -1
  129. package/lib/esm/runtime/interactive-box.d.ts +0 -40
  130. package/lib/esm/runtime/interactive-box.mjs +0 -33
  131. package/lib/esm/runtime/interactive-box.mjs.map +0 -1
  132. package/src/metro/transform-ast.ts +0 -1729
  133. package/src/runtime/haptics.ts +0 -120
  134. package/src/runtime/interactive-box.tsx +0 -57
@@ -4,8 +4,8 @@ import { parse } from '@babel/parser'
4
4
  import generate from '@babel/generator'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { realpathSync } from 'node:fs'
7
- import { transformAst } from './transform-ast'
8
- import { getClassNamePrefixes, getHostComponents, getHostSources, getRnwindCacheKey, getRnwindState, onThemeChange } from './state'
7
+ import { getRnwindCacheKey, getRnwindState, getWrapModules, onThemeChange } from './state'
8
+ import { rewriteWrapImports } from './wrap-imports'
9
9
  import { STYLE_SPECIFIERS, THEME_SIGNATURE_MODULE } from './resolver'
10
10
  import { filterUnknownClassCandidates } from './warn-unknown-classes'
11
11
 
@@ -62,15 +62,23 @@ function parseUserSource(source: string): File | null {
62
62
  * @param candidates Every candidate oxide surfaced from the source.
63
63
  * @param atoms Successfully resolved atoms (keys are class names).
64
64
  * @param filename Source path, prefixed onto the warning.
65
+ * @param features Feature-atom maps (gradient / haptic) — their names are
66
+ * known classes even though they carry no RN style, so they're excluded
67
+ * from the unknown-class warning.
65
68
  */
66
69
  function warnUnknownClasses(
67
70
  source: string,
68
71
  candidates: readonly string[],
69
72
  atoms: ReadonlyMap<string, unknown>,
70
73
  filename: string,
74
+ features: ReadonlyArray<ReadonlyMap<string, unknown>> = [],
71
75
  ): void {
72
- const atomNames = new Set(atoms.keys())
73
- const unknown = filterUnknownClassCandidates(source, candidates, atomNames)
76
+ // Feature atoms (gradient / haptic) resolve to no RN style, so they're
77
+ // absent from `atoms` — but they're NOT unknown. Fold their names into
78
+ // the known set so `active:haptic-rigid` etc. don't warn at build time.
79
+ const known = new Set(atoms.keys())
80
+ for (const map of features) for (const name of map.keys()) known.add(name)
81
+ const unknown = filterUnknownClassCandidates(source, candidates, known)
74
82
  if (unknown.length === 0) return
75
83
  // eslint-disable-next-line no-console
76
84
  console.warn(`rnwind: unknown class${unknown.length > 1 ? 'es' : ''} in ${filename}: ${unknown.join(', ')}`)
@@ -114,56 +122,183 @@ function isThemeCssEntry(filename: string): boolean {
114
122
  }
115
123
 
116
124
  /**
117
- * Parse + run rnwind's JSX rewrite + regenerate source code. When
118
- * parsing or transformation fails, fall back to the original source —
119
- * we don't want a transient parse error to crash Metro for a file the
120
- * upstream might handle fine.
125
+ * Wrap host imports + compile any className literals, then regenerate
126
+ * source. Two paths:
127
+ * - **className present**: oxide-scan the file, record its atoms into
128
+ * the union, and inject the generated-style + theme-signature
129
+ * side-effect imports so the runtime registries populate.
130
+ * - **import-only** (a `{...rest}` forwarder or a leaf with no literal
131
+ * `className=`): just wrap the host imports so a forwarded className
132
+ * still resolves at render — no oxide scan, no injected imports.
133
+ *
134
+ * On parse failure, fall back to the original source — a transient parse
135
+ * error shouldn't crash Metro for a file the upstream might handle fine.
121
136
  * @param args Metro args; `src` is the original source text.
122
- * @returns Rewritten source text (with `className=` rewrites applied).
137
+ * @returns Rewritten source text.
123
138
  */
124
139
  async function rewriteSource(args: BabelTransformerArgs): Promise<string> {
125
140
  const ast = parseUserSource(args.src)
126
141
  if (!ast) return args.src
127
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
+
128
156
  const state = getRnwindState(projectRootOf(args))
129
157
  const extension = extensionOf(args.filename)
130
158
  const parsed = await state.parser.parseAtoms({ content: args.src, extension })
131
159
 
132
- warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename)
160
+ warnUnknownClasses(args.src, parsed.candidates, parsed.atoms, args.filename, [parsed.gradientAtoms, parsed.hapticAtoms])
133
161
 
134
- const classNamePrefixes = getClassNamePrefixes()
135
- const hostSources = getHostSources()
136
- const hostComponents = getHostComponents()
137
162
  if (parsed.atoms.size === 0) {
138
163
  state.builder.dropFile(args.filename)
139
164
  await state.builder.writeSchemes()
140
- transformAst(ast, {
141
- styleSpecifiers: [],
142
- gradientAtoms: parsed.gradientAtoms,
143
- hapticAtoms: parsed.hapticAtoms,
144
- classNamePrefixes,
145
- hostSources,
146
- hostComponents,
147
- })
148
165
  injectThemeSignatureImport(ast)
149
166
  return generateModule(ast).code
150
167
  }
151
168
 
152
- const { changed } = await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes)
169
+ const literals = collectClassNameLiterals(ast)
170
+ const { changed } = await state.builder.recordFile(args.filename, parsed.atoms, parsed.keyframes, literals)
153
171
  if (changed) await state.builder.writeSchemes()
154
172
 
155
- transformAst(ast, {
156
- styleSpecifiers: STYLE_SPECIFIERS as unknown as readonly string[],
157
- gradientAtoms: parsed.gradientAtoms,
158
- hapticAtoms: parsed.hapticAtoms,
159
- classNamePrefixes,
160
- hostSources,
161
- hostComponents,
162
- })
173
+ injectSideEffectImports(ast, STYLE_SPECIFIERS)
163
174
  injectThemeSignatureImport(ast)
164
175
  return generateModule(ast).code
165
176
  }
166
177
 
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
+ /**
194
+ * Whether a JSX attribute names a className-style prop (`className` or
195
+ * any `<prefix>ClassName`).
196
+ * @param node JSX attribute node.
197
+ * @returns True when the attribute is a className prop.
198
+ */
199
+ function isClassNameAttribute(node: t.JSXAttribute): boolean {
200
+ if (!t.isJSXIdentifier(node.name)) return false
201
+ const {name} = node.name
202
+ return name === 'className' || name.endsWith('ClassName')
203
+ }
204
+
205
+ /**
206
+ * Pull static string literals out of a className expression. Handles a
207
+ * bare string, a no-substitution template, and the branches of a
208
+ * ternary / `&&` (so `cond ? 'a' : 'b'` and `flag && 'x'` both register
209
+ * their literals). Dynamic interpolations are skipped — they resolve via
210
+ * the runtime atom path.
211
+ * @param expr Expression inside a `className={...}` container.
212
+ * @param out Accumulator for discovered literals.
213
+ */
214
+ function collectLiteralsFromExpression(expr: t.Expression | t.JSXEmptyExpression | null | undefined, out: string[]): void {
215
+ if (!expr) return
216
+ if (t.isStringLiteral(expr)) {
217
+ out.push(expr.value)
218
+ return
219
+ }
220
+ if (t.isTemplateLiteral(expr) && expr.expressions.length === 0 && expr.quasis.length === 1) {
221
+ const cooked = expr.quasis[0]?.value.cooked
222
+ if (typeof cooked === 'string') out.push(cooked)
223
+ return
224
+ }
225
+ if (t.isConditionalExpression(expr)) {
226
+ collectLiteralsFromExpression(expr.consequent, out)
227
+ collectLiteralsFromExpression(expr.alternate, out)
228
+ return
229
+ }
230
+ if (t.isLogicalExpression(expr)) {
231
+ collectLiteralsFromExpression(expr.right as t.Expression, out)
232
+ }
233
+ }
234
+
235
+ /** AST node keys the literal walk skips — position / comment metadata. */
236
+ const SKIP_WALK_KEYS = new Set(['type', 'loc', 'start', 'end', 'range', 'leadingComments', 'trailingComments', 'innerComments'])
237
+
238
+ /**
239
+ * Collect the static literals from one className JSX attribute into the
240
+ * dedup accumulator.
241
+ * @param attribute The (already className-matched) JSX attribute.
242
+ * @param seen Dedup set of literals already collected.
243
+ * @param out Ordered accumulator.
244
+ */
245
+ function collectAttributeLiterals(attribute: t.JSXAttribute, seen: Set<string>, out: string[]): void {
246
+ const { value } = attribute
247
+ const found: string[] = []
248
+ if (t.isStringLiteral(value)) found.push(value.value)
249
+ else if (t.isJSXExpressionContainer(value)) collectLiteralsFromExpression(value.expression, found)
250
+ for (const literal of found) {
251
+ if (seen.has(literal)) continue
252
+ seen.add(literal)
253
+ out.push(literal)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Walk the AST for every `className=` / `<prefix>ClassName=` literal so
259
+ * the builder can pre-merge each into a per-scheme molecule. A generic
260
+ * node walk (no scope build) keeps it cheap; only JSX attribute nodes do
261
+ * any work.
262
+ * @param ast Parsed Babel file.
263
+ * @returns Distinct literal className strings, in first-seen order.
264
+ */
265
+ function collectClassNameLiterals(ast: File): readonly string[] {
266
+ const out: string[] = []
267
+ const seen = new Set<string>()
268
+ const visit = (node: unknown): void => {
269
+ if (!node || typeof node !== 'object') return
270
+ if (Array.isArray(node)) {
271
+ for (const child of node) visit(child)
272
+ return
273
+ }
274
+ const typed = node as { type?: string; [key: string]: unknown }
275
+ if (typeof typed.type !== 'string') return
276
+ if (typed.type === 'JSXAttribute' && isClassNameAttribute(node as t.JSXAttribute)) {
277
+ collectAttributeLiterals(node as t.JSXAttribute, seen, out)
278
+ }
279
+ for (const key in typed) {
280
+ if (SKIP_WALK_KEYS.has(key)) continue
281
+ visit(typed[key])
282
+ }
283
+ }
284
+ visit(ast.program)
285
+ return out
286
+ }
287
+
288
+ /**
289
+ * Prepend side-effect imports (`import '<spec>'`) so the generated
290
+ * per-scheme style + manifest modules load — registering this file's
291
+ * atoms / molecules / features into the runtime registries the wrapper's
292
+ * `resolve` reads.
293
+ * @param ast Babel File AST to mutate in place.
294
+ * @param specifiers Module specifiers to side-effect-import.
295
+ */
296
+ function injectSideEffectImports(ast: File, specifiers: readonly string[]): void {
297
+ for (const specifier of specifiers) {
298
+ ast.program.body.unshift(t.importDeclaration([], t.stringLiteral(specifier)))
299
+ }
300
+ }
301
+
167
302
  /**
168
303
  * Prepend `import 'rnwind/__generated/theme-signature'` to every
169
304
  * rnwind-transformed file. The resolver maps that specifier to the
@@ -252,10 +387,16 @@ function loadUpstream(): UpstreamTransformer | null {
252
387
  */
253
388
  function isRewriteCandidate(args: BabelTransformerArgs): boolean {
254
389
  if (!/\.(?:tsx|ts|jsx|js)$/i.test(args.filename)) return false
255
- // Case-insensitive so `<prefix>ClassName=` (e.g. `contentContainerClassName=`)
256
- // which has a capital `C` and so doesn't contain the lowercase
257
- // `className=` still routes the file through the rewrite pass.
258
- if (!/classname=/i.test(args.src)) 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
259
400
  if (!args.filename.includes('/node_modules/')) return true
260
401
  // node_modules in path → could be a workspace symlink; resolve it.
261
402
  try {
@@ -266,6 +407,21 @@ function isRewriteCandidate(args: BabelTransformerArgs): boolean {
266
407
  }
267
408
  }
268
409
 
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
+
269
425
  /**
270
426
  * Fallback parse when no upstream is configured AND Metro didn't hand
271
427
  * us an AST. Used by unit tests and standalone setups.
@@ -127,35 +127,20 @@ export interface RnwindMetroOptions {
127
127
  /** Cache directory. Absolute, or relative to `projectRoot`. Default: `.rnwind` at project root. */
128
128
  cacheDir?: string
129
129
  /**
130
- * Extra JSX prop-name prefixes that rnwind should rewrite. Each
131
- * prefix `P` turns `<Tag PClassName="…">` into `<Tag
132
- * PStyle={lookupCss()}>`. The built-in `'contentContainer'` prefix
133
- * is always on (covers ScrollView / FlatList / SectionList); user
134
- * entries merge on top.
135
- */
136
- classNamePrefixes?: readonly string[]
137
- /**
138
- * Extra module specifiers whose JSX exports rnwind should treat as
139
- * "host components" — i.e. tags whose `className="…"` attribute is
140
- * rewritten to `style={lookupCss(…)}` at build time (zero runtime
141
- * cost). Merged with the built-in defaults: `react-native`,
130
+ * Extra module specifiers whose component exports rnwind should
131
+ * auto-wrap at import sites — `import { View } from 'react-native'`
132
+ * becomes `const View = wrap(_rnw0)` so `<View className="…">` resolves
133
+ * styles at runtime. Merged with the built-in defaults: `react-native`,
142
134
  * `react-native-reanimated`, `react-native-svg`,
143
- * `react-native-gesture-handler`, `expo-linear-gradient`, `expo-image`.
135
+ * `react-native-gesture-handler`, `react-native-safe-area-context`,
136
+ * `expo-linear-gradient`, `expo-image`, and more.
144
137
  *
145
- * Anything NOT marked as a host has its `className` left untouched
146
- * the importing component receives the raw string and decides what
147
- * to do with it. Use this option to opt your design-system / UI
148
- * primitive packages into the zero-runtime path.
149
- */
150
- hostSources?: readonly string[]
151
- /**
152
- * Extra JSX tag names (verbatim — may include `.` for member access
153
- * like `'Animated.View'`) rnwind should treat as host components,
154
- * regardless of where they're imported from. Useful for one-off
155
- * escape-hatches: `import { View as MyBox } from 'react-native'`
156
- * doesn't change the local name → `'MyBox'` here picks it up.
138
+ * A module NOT in this list keeps its raw imports the importing
139
+ * component receives `className` as a plain prop and can resolve it
140
+ * via `useCss` / `wrap` itself. Use this to opt your design-system /
141
+ * UI primitive packages into the auto-wrap path.
157
142
  */
158
- hostComponents?: readonly string[]
143
+ wrapModules?: readonly string[]
159
144
  }
160
145
 
161
146
  /** Shape we mutate on Metro's config. Loose so we don't pin Metro's internal types. */
@@ -202,7 +187,7 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
202
187
 
203
188
  mkdirSync(cacheDir, { recursive: true })
204
189
  const watchFolders = (metroConfig.watchFolders ?? []).filter((p) => typeof p === 'string' && p.length > 0)
205
- configureRnwindState(cssEntry, cacheDir, watchFolders, options.classNamePrefixes, options.hostSources, options.hostComponents)
190
+ configureRnwindState(cssEntry, cacheDir, watchFolders, options.wrapModules)
206
191
 
207
192
  // Warm the state eagerly (in the Metro master process) so oxide's
208
193
  // Scanner walks every project source (and every monorepo
@@ -238,7 +223,7 @@ export function withRnwindConfig<C extends MetroConfigLike>(metroConfig: C, opti
238
223
  if (options.dtsFile !== false) {
239
224
  const dtsPath = options.dtsFile ?? path.resolve(projectRoot, 'rnwind-types.d.ts')
240
225
  const schemes = discoverSchemes(cssEntry, projectRoot)
241
- writeDtsFile(dtsPath, schemes, options.classNamePrefixes)
226
+ writeDtsFile(dtsPath, schemes)
242
227
  }
243
228
 
244
229
  // Watch the theme CSS. On edit, we rewrite scheme files AND touch
@@ -0,0 +1,260 @@
1
+ import * as t from '@babel/types'
2
+ import type { File } from '@babel/types'
3
+
4
+ /**
5
+ * Build-time import rewrite. For every `import { View } from
6
+ * 'react-native'` (and the other configured modules) it aliases the
7
+ * original export and binds a `wrap()`-ed component in its place:
8
+ *
9
+ * ```
10
+ * import { View, StyleSheet } from 'react-native'
11
+ * ⇩
12
+ * import { View as _rnw0, StyleSheet } from 'react-native'
13
+ * import { wrap as _rnwWrap } from 'rnwind'
14
+ * const View = _rnwWrap(_rnw0)
15
+ * ```
16
+ *
17
+ * Now `<View className="…">` resolves className → style at render via the
18
+ * wrapper — no matter how className arrived (literal, `{...rest}` spread,
19
+ * forwarded through custom layers). Non-component exports (`StyleSheet`)
20
+ * are left untouched.
21
+ */
22
+
23
+ /** Local binding the injected `wrap` import is aliased to. */
24
+ const WRAP_LOCAL = '_rnwWrap'
25
+ /** Local binding the injected `wrapNamespace` import is aliased to. */
26
+ const WRAP_NS_LOCAL = '_rnwWrapNs'
27
+ /** Module the wrapper is imported from. */
28
+ const RUNTIME_MODULE = 'rnwind'
29
+
30
+ /**
31
+ * Wrap-modules whose DEFAULT export is a component NAMESPACE accessed via
32
+ * member expressions (`Animated.View`), not a single component. Their
33
+ * default import is bound through `wrapNamespace` (a Proxy that wraps each
34
+ * accessed component member) instead of `wrap`. Every other default import
35
+ * is treated as a plain component.
36
+ */
37
+ const NAMESPACE_DEFAULT_MODULES: ReadonlySet<string> = new Set(['react-native-reanimated'])
38
+
39
+ /**
40
+ * react-native mixes styleable components with utilities (`StyleSheet`,
41
+ * `Platform`, …). Only these named exports are wrapped; everything else
42
+ * passes through. Other ecosystem modules export components only and use
43
+ * the `'all'` policy instead.
44
+ */
45
+ const REACT_NATIVE_COMPONENTS: ReadonlySet<string> = new Set([
46
+ 'View',
47
+ 'Text',
48
+ 'TextInput',
49
+ 'Pressable',
50
+ 'ScrollView',
51
+ 'Image',
52
+ 'ImageBackground',
53
+ 'FlatList',
54
+ 'SectionList',
55
+ 'VirtualizedList',
56
+ 'KeyboardAvoidingView',
57
+ 'SafeAreaView',
58
+ 'Modal',
59
+ 'Switch',
60
+ 'RefreshControl',
61
+ 'ActivityIndicator',
62
+ 'TouchableOpacity',
63
+ 'TouchableHighlight',
64
+ 'TouchableWithoutFeedback',
65
+ 'TouchableNativeFeedback',
66
+ 'Button',
67
+ 'StatusBar',
68
+ ])
69
+
70
+ /**
71
+ * Named exports that LOOK like components (PascalCase) under an `'all'`
72
+ * policy but aren't — React contexts, gesture-handler enums/namespaces,
73
+ * etc. Wrapping these would turn `Gesture.Pan()` / `State.ACTIVE` /
74
+ * `<XContext.Provider>` into a `wrap()`-ed component and break them.
75
+ * Names ending in `Context` are excluded separately.
76
+ */
77
+ const NON_COMPONENT_EXPORTS: ReadonlySet<string> = new Set([
78
+ 'Gesture',
79
+ 'GestureObjects',
80
+ 'State',
81
+ 'Directions',
82
+ 'Extrapolation',
83
+ 'Extrapolate',
84
+ 'Easing',
85
+ 'ReduceMotion',
86
+ 'KeyframeRegistry',
87
+ ])
88
+
89
+ /** Per-module policy: an explicit allow-list, or `'all'` named exports. */
90
+ export type WrapPolicy = 'all' | ReadonlySet<string>
91
+
92
+ /**
93
+ * Default module → wrap policy. react-native is allow-listed (mixed
94
+ * exports); the rest are component-only packages → `'all'`. Only modules
95
+ * the project has installed are ever hit (you can't import from a missing
96
+ * package), so listing optional peers is free.
97
+ */
98
+ export const DEFAULT_WRAP_MODULES: ReadonlyMap<string, WrapPolicy> = new Map<string, WrapPolicy>([
99
+ ['react-native', REACT_NATIVE_COMPONENTS],
100
+ ['react-native-reanimated', 'all'],
101
+ ['react-native-svg', 'all'],
102
+ ['react-native-gesture-handler', 'all'],
103
+ ['react-native-safe-area-context', 'all'],
104
+ ['expo-linear-gradient', 'all'],
105
+ ['expo-image', 'all'],
106
+ ['expo-blur', 'all'],
107
+ ['expo-symbols', 'all'],
108
+ ['@shopify/flash-list', 'all'],
109
+ ['@shopify/react-native-skia', 'all'],
110
+ ['lottie-react-native', 'all'],
111
+ ])
112
+
113
+ /**
114
+ * Whether a named import from a wrap-module should be wrapped.
115
+ *
116
+ * Explicit allow-lists (react-native) match by exact name. The `'all'`
117
+ * policy wraps only component-style names — PascalCase, not a React
118
+ * context (`*Context`), and not a known non-component export. This is
119
+ * what stops `useSafeAreaInsets` (a hook) from being wrapped into a
120
+ * component and crashing when called.
121
+ * @param policy The module's wrap policy.
122
+ * @param importedName The exported name being imported.
123
+ * @returns True when the name is a component to wrap.
124
+ */
125
+ function shouldWrap(policy: WrapPolicy, importedName: string): boolean {
126
+ if (policy !== 'all') return policy.has(importedName)
127
+ if (!/^[A-Z]/.test(importedName)) return false
128
+ if (importedName.endsWith('Context')) return false
129
+ return !NON_COMPONENT_EXPORTS.has(importedName)
130
+ }
131
+
132
+ /**
133
+ * Merge user-supplied wrap modules onto the defaults — a bare module name
134
+ * adopts the `'all'` policy.
135
+ * @param extra User module specifiers (or undefined).
136
+ * @returns Effective module → policy map.
137
+ */
138
+ export function buildWrapModules(extra?: readonly string[]): ReadonlyMap<string, WrapPolicy> {
139
+ if (!extra || extra.length === 0) return DEFAULT_WRAP_MODULES
140
+ const merged = new Map<string, WrapPolicy>(DEFAULT_WRAP_MODULES)
141
+ for (const moduleName of extra) if (!merged.has(moduleName)) merged.set(moduleName, 'all')
142
+ return merged
143
+ }
144
+
145
+ /**
146
+ * The `imported` name of an import specifier (`import { a as b }` → `'a'`).
147
+ * @param specifier Named import specifier.
148
+ * @returns The exported name.
149
+ */
150
+ function importedNameOf(specifier: t.ImportSpecifier): string {
151
+ return t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value
152
+ }
153
+
154
+ /**
155
+ * Build `const Local = <wrapper>(alias)` and rebind the specifier's local
156
+ * to `alias` in place.
157
+ * @param specifier The import specifier to rebind.
158
+ * @param alias The `_rnwN` alias to bind the original import to.
159
+ * @param wrapper The runtime helper local (`_rnwWrap` / `_rnwWrapNs`).
160
+ * @returns The wrap declaration.
161
+ */
162
+ function makeWrapDecl(
163
+ specifier: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier,
164
+ alias: string,
165
+ wrapper: string,
166
+ ): t.VariableDeclaration {
167
+ const { name: localName } = specifier.local
168
+ specifier.local = t.identifier(alias)
169
+ return t.variableDeclaration('const', [
170
+ t.variableDeclarator(t.identifier(localName), t.callExpression(t.identifier(wrapper), [t.identifier(alias)])),
171
+ ])
172
+ }
173
+
174
+ /**
175
+ * Rewrite one import declaration's wrappable specifiers, aliasing each to
176
+ * `_rnw<N>` in place:
177
+ * - named (`{ View }`) → `const View = wrap(_rnwN)` (per policy),
178
+ * - namespace (`* as Animated`) → `const Animated = wrapNamespace(_rnwN)`,
179
+ * - default → `wrapNamespace` for {@link NAMESPACE_DEFAULT_MODULES}
180
+ * (reanimated's `Animated`), else `wrap` (a plain default component).
181
+ * @param node Import declaration to inspect.
182
+ * @param policy The module's wrap policy.
183
+ * @param counter Next alias index (caller-threaded for uniqueness).
184
+ * @returns New wrap declarations, advanced counter, and whether any
185
+ * binding used `wrapNamespace`.
186
+ */
187
+ function wrapSpecifiers(
188
+ node: t.ImportDeclaration,
189
+ policy: WrapPolicy,
190
+ counter: number,
191
+ ): { decls: t.VariableDeclaration[]; counter: number; usesNamespace: boolean } {
192
+ const decls: t.VariableDeclaration[] = []
193
+ const moduleName = node.source.value
194
+ let next = counter
195
+ let usesNamespace = false
196
+ for (const specifier of node.specifiers) {
197
+ if (t.isImportSpecifier(specifier)) {
198
+ if (!shouldWrap(policy, importedNameOf(specifier))) continue
199
+ decls.push(makeWrapDecl(specifier, `_rnw${next}`, WRAP_LOCAL))
200
+ next += 1
201
+ continue
202
+ }
203
+ const isNamespace = t.isImportNamespaceSpecifier(specifier) || NAMESPACE_DEFAULT_MODULES.has(moduleName)
204
+ const wrapper = isNamespace ? WRAP_NS_LOCAL : WRAP_LOCAL
205
+ decls.push(makeWrapDecl(specifier, `_rnw${next}`, wrapper))
206
+ next += 1
207
+ if (isNamespace) usesNamespace = true
208
+ }
209
+ return { decls, counter: next, usesNamespace }
210
+ }
211
+
212
+ /**
213
+ * Insert the `wrap` import at the top and the `const X = wrap(_rnwN)`
214
+ * declarations AFTER every import. The consts reference the aliased
215
+ * binding `_rnwN`, and in Metro's real transform (CommonJS interop + the
216
+ * reanimated worklets plugin) a const placed above its source import
217
+ * evaluates before the binding initialises → `ReferenceError: _rnw0
218
+ * doesn't exist`. ESM-only hoisting would mask this; the bundle does not.
219
+ * @param ast Parsed Babel file (mutated).
220
+ * @param wrapDecls The wrap declarations to place.
221
+ * @param usesNamespace Whether any binding used `wrapNamespace`.
222
+ */
223
+ function placeWrapDecls(ast: File, wrapDecls: readonly t.VariableDeclaration[], usesNamespace: boolean): void {
224
+ const specifiers = [t.importSpecifier(t.identifier(WRAP_LOCAL), t.identifier('wrap'))]
225
+ if (usesNamespace) specifiers.push(t.importSpecifier(t.identifier(WRAP_NS_LOCAL), t.identifier('wrapNamespace')))
226
+ ast.program.body.unshift(t.importDeclaration(specifiers, t.stringLiteral(RUNTIME_MODULE)))
227
+ let afterImports = 0
228
+ for (let index = 0; index < ast.program.body.length; index += 1) {
229
+ if (t.isImportDeclaration(ast.program.body[index])) afterImports = index + 1
230
+ }
231
+ ast.program.body.splice(afterImports, 0, ...wrapDecls)
232
+ }
233
+
234
+ /**
235
+ * Rewrite component imports from the configured wrap-modules into
236
+ * `wrap()`-ed bindings, in place. Injects the `wrap` import once when any
237
+ * binding was rewritten.
238
+ * @param ast Parsed Babel file (mutated).
239
+ * @param modules Effective module → policy map.
240
+ * @returns True when at least one import was wrapped.
241
+ */
242
+ export function rewriteWrapImports(ast: File, modules: ReadonlyMap<string, WrapPolicy>): boolean {
243
+ const wrapDecls: t.VariableDeclaration[] = []
244
+ let counter = 0
245
+ let usesNamespace = false
246
+
247
+ for (const node of ast.program.body) {
248
+ if (!t.isImportDeclaration(node)) continue
249
+ const policy = modules.get(node.source.value)
250
+ if (!policy) continue
251
+ const { decls, counter: nextCounter, usesNamespace: ns } = wrapSpecifiers(node, policy, counter)
252
+ counter = nextCounter
253
+ usesNamespace = usesNamespace || ns
254
+ wrapDecls.push(...decls)
255
+ }
256
+
257
+ if (wrapDecls.length === 0) return false
258
+ placeWrapDecls(ast, wrapDecls, usesNamespace)
259
+ return true
260
+ }
@@ -96,23 +96,6 @@ export function useRnwind(): RnwindState {
96
96
  return useContext(RnwindContext)
97
97
  }
98
98
 
99
- /**
100
- * Internal context hook the babel transformer injects at the top of
101
- * every rewritten component as `const _t = useR_()`. Same body as the
102
- * public {@link useRnwind} — exposed under a `use*`-prefixed name so
103
- * react-refresh's babel plugin (which only tracks call-sites whose
104
- * identifier matches `^use[A-Z]`) folds it into each component's
105
- * fast-refresh signature. Without that prefix the signature stayed
106
- * stable across transformer changes; HMR then preserved fiber state
107
- * while the rendered hook list shifted, surfacing as "change in the
108
- * order of Hooks" runtime errors. Trailing underscore keeps it
109
- * visually distinct from the user-facing `useRnwind`.
110
- * @returns Active rnwind state.
111
- */
112
- export function useR_(): RnwindState {
113
- return useContext(RnwindContext)
114
- }
115
-
116
99
  /**
117
100
  * Provider for rnwind's full runtime state. fontScale + windowWidth
118
101
  * come from `useWindowDimensions()` so they react to OS-level
@@ -1,16 +1,22 @@
1
1
  import { useRnwind } from '../components/rnwind-provider'
2
- import { lookupCss } from '../lookup-css'
2
+ import { resolve } from '../resolve'
3
3
 
4
4
  /**
5
- * Convenience hook: `useRnwind()` + `lookupCss()` rolled into one. Use
6
- * inside any component that wants the resolved style array without
7
- * threading the rnwind context manually. JSX-heavy components should
8
- * still call `useRnwind()` once and pass it to `lookupCss(...)` per
9
- * element so React only does a single context read per render.
10
- * @param className Raw className string or transformer-hoisted atom-name array.
11
- * @param userStyle Optional caller-supplied style appended last.
12
- * @returns Frozen style array for React Native's `style` prop.
5
+ * Resolve a className to a React Native `style` value against the active
6
+ * rnwind context (scheme, insets, fontScale, breakpoint). Molecule-fast:
7
+ * a literal className the scanner saw returns a pre-merged object by
8
+ * reference; anything else falls back to per-atom resolution. The escape
9
+ * hatch for custom components that hold a `className` prop:
10
+ *
11
+ * ```tsx
12
+ * function Card({ className, style, ...rest }) {
13
+ * return <RNView style={useCss(className, style)} {...rest} />
14
+ * }
15
+ * ```
16
+ * @param className Raw className string.
17
+ * @param userStyle Optional caller-supplied style appended last (wins).
18
+ * @returns RN `style` value (a single object or an array).
13
19
  */
14
- export function useCss(className?: string | readonly string[] | null, userStyle?: unknown): readonly unknown[] {
15
- return lookupCss(className, useRnwind(), userStyle)
20
+ export function useCss(className?: string | null, userStyle?: unknown): unknown {
21
+ return resolve(className, useRnwind(), userStyle).style
16
22
  }