rnwind 0.0.4 → 0.0.6

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 (127) 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/dts.cjs +7 -16
  11. package/lib/cjs/metro/dts.cjs.map +1 -1
  12. package/lib/cjs/metro/dts.d.ts +2 -4
  13. package/lib/cjs/metro/state.cjs +30 -78
  14. package/lib/cjs/metro/state.cjs.map +1 -1
  15. package/lib/cjs/metro/state.d.ts +8 -25
  16. package/lib/cjs/metro/transformer.cjs +193 -34
  17. package/lib/cjs/metro/transformer.cjs.map +1 -1
  18. package/lib/cjs/metro/with-config.cjs +2 -2
  19. package/lib/cjs/metro/with-config.cjs.map +1 -1
  20. package/lib/cjs/metro/with-config.d.ts +11 -26
  21. package/lib/cjs/metro/wrap-imports.cjs +273 -0
  22. package/lib/cjs/metro/wrap-imports.cjs.map +1 -0
  23. package/lib/cjs/metro/wrap-imports.d.ts +26 -0
  24. package/lib/cjs/runtime/components/rnwind-provider.cjs +0 -17
  25. package/lib/cjs/runtime/components/rnwind-provider.cjs.map +1 -1
  26. package/lib/cjs/runtime/components/rnwind-provider.d.ts +0 -14
  27. package/lib/cjs/runtime/hooks/use-css.cjs +16 -10
  28. package/lib/cjs/runtime/hooks/use-css.cjs.map +1 -1
  29. package/lib/cjs/runtime/hooks/use-css.d.ts +15 -9
  30. package/lib/cjs/runtime/index.cjs +11 -13
  31. package/lib/cjs/runtime/index.cjs.map +1 -1
  32. package/lib/cjs/runtime/index.d.ts +4 -9
  33. package/lib/cjs/runtime/lookup-css.cjs +10 -0
  34. package/lib/cjs/runtime/lookup-css.cjs.map +1 -1
  35. package/lib/cjs/runtime/lookup-css.d.ts +7 -0
  36. package/lib/cjs/runtime/resolve.cjs +400 -0
  37. package/lib/cjs/runtime/resolve.cjs.map +1 -0
  38. package/lib/cjs/runtime/resolve.d.ts +66 -0
  39. package/lib/cjs/runtime/wrap.cjs +254 -0
  40. package/lib/cjs/runtime/wrap.cjs.map +1 -0
  41. package/lib/cjs/runtime/wrap.d.ts +37 -0
  42. package/lib/cjs/testing/index.cjs +81 -50
  43. package/lib/cjs/testing/index.cjs.map +1 -1
  44. package/lib/esm/core/normalize-classname.d.ts +10 -0
  45. package/lib/esm/core/normalize-classname.mjs +23 -0
  46. package/lib/esm/core/normalize-classname.mjs.map +1 -0
  47. package/lib/esm/core/style-builder/build-style.d.ts +6 -1
  48. package/lib/esm/core/style-builder/build-style.mjs +258 -58
  49. package/lib/esm/core/style-builder/build-style.mjs.map +1 -1
  50. package/lib/esm/core/style-builder/union-builder.d.ts +21 -1
  51. package/lib/esm/core/style-builder/union-builder.mjs +37 -3
  52. package/lib/esm/core/style-builder/union-builder.mjs.map +1 -1
  53. package/lib/esm/metro/dts.d.ts +2 -4
  54. package/lib/esm/metro/dts.mjs +7 -16
  55. package/lib/esm/metro/dts.mjs.map +1 -1
  56. package/lib/esm/metro/state.d.ts +8 -25
  57. package/lib/esm/metro/state.mjs +30 -76
  58. package/lib/esm/metro/state.mjs.map +1 -1
  59. package/lib/esm/metro/transformer.mjs +194 -35
  60. package/lib/esm/metro/transformer.mjs.map +1 -1
  61. package/lib/esm/metro/with-config.d.ts +11 -26
  62. package/lib/esm/metro/with-config.mjs +2 -2
  63. package/lib/esm/metro/with-config.mjs.map +1 -1
  64. package/lib/esm/metro/wrap-imports.d.ts +26 -0
  65. package/lib/esm/metro/wrap-imports.mjs +250 -0
  66. package/lib/esm/metro/wrap-imports.mjs.map +1 -0
  67. package/lib/esm/runtime/components/rnwind-provider.d.ts +0 -14
  68. package/lib/esm/runtime/components/rnwind-provider.mjs +1 -17
  69. package/lib/esm/runtime/components/rnwind-provider.mjs.map +1 -1
  70. package/lib/esm/runtime/hooks/use-css.d.ts +15 -9
  71. package/lib/esm/runtime/hooks/use-css.mjs +16 -10
  72. package/lib/esm/runtime/hooks/use-css.mjs.map +1 -1
  73. package/lib/esm/runtime/index.d.ts +4 -9
  74. package/lib/esm/runtime/index.mjs +4 -4
  75. package/lib/esm/runtime/index.mjs.map +1 -1
  76. package/lib/esm/runtime/lookup-css.d.ts +7 -0
  77. package/lib/esm/runtime/lookup-css.mjs +10 -1
  78. package/lib/esm/runtime/lookup-css.mjs.map +1 -1
  79. package/lib/esm/runtime/resolve.d.ts +66 -0
  80. package/lib/esm/runtime/resolve.mjs +393 -0
  81. package/lib/esm/runtime/resolve.mjs.map +1 -0
  82. package/lib/esm/runtime/wrap.d.ts +37 -0
  83. package/lib/esm/runtime/wrap.mjs +251 -0
  84. package/lib/esm/runtime/wrap.mjs.map +1 -0
  85. package/lib/esm/testing/index.mjs +84 -53
  86. package/lib/esm/testing/index.mjs.map +1 -1
  87. package/package.json +2 -1
  88. package/src/core/normalize-classname.ts +19 -0
  89. package/src/core/style-builder/build-style.ts +286 -55
  90. package/src/core/style-builder/union-builder.ts +36 -3
  91. package/src/metro/dts.ts +7 -19
  92. package/src/metro/state.ts +29 -74
  93. package/src/metro/transformer.ts +190 -34
  94. package/src/metro/with-config.ts +13 -28
  95. package/src/metro/wrap-imports.ts +260 -0
  96. package/src/runtime/components/rnwind-provider.tsx +0 -17
  97. package/src/runtime/hooks/use-css.ts +17 -11
  98. package/src/runtime/index.ts +3 -26
  99. package/src/runtime/lookup-css.ts +10 -0
  100. package/src/runtime/resolve.ts +438 -0
  101. package/src/runtime/wrap.tsx +267 -0
  102. package/src/testing/index.ts +106 -56
  103. package/lib/cjs/core/parser/text-truncate.cjs +0 -78
  104. package/lib/cjs/core/parser/text-truncate.cjs.map +0 -1
  105. package/lib/cjs/metro/transform-ast.cjs +0 -1472
  106. package/lib/cjs/metro/transform-ast.cjs.map +0 -1
  107. package/lib/cjs/metro/transform-ast.d.ts +0 -88
  108. package/lib/cjs/runtime/haptics.cjs +0 -113
  109. package/lib/cjs/runtime/haptics.cjs.map +0 -1
  110. package/lib/cjs/runtime/haptics.d.ts +0 -48
  111. package/lib/cjs/runtime/interactive-box.cjs +0 -35
  112. package/lib/cjs/runtime/interactive-box.cjs.map +0 -1
  113. package/lib/cjs/runtime/interactive-box.d.ts +0 -40
  114. package/lib/esm/core/parser/text-truncate.mjs +0 -75
  115. package/lib/esm/core/parser/text-truncate.mjs.map +0 -1
  116. package/lib/esm/metro/transform-ast.d.ts +0 -88
  117. package/lib/esm/metro/transform-ast.mjs +0 -1451
  118. package/lib/esm/metro/transform-ast.mjs.map +0 -1
  119. package/lib/esm/runtime/haptics.d.ts +0 -48
  120. package/lib/esm/runtime/haptics.mjs +0 -110
  121. package/lib/esm/runtime/haptics.mjs.map +0 -1
  122. package/lib/esm/runtime/interactive-box.d.ts +0 -40
  123. package/lib/esm/runtime/interactive-box.mjs +0 -33
  124. package/lib/esm/runtime/interactive-box.mjs.map +0 -1
  125. package/src/metro/transform-ast.ts +0 -1729
  126. package/src/runtime/haptics.ts +0 -120
  127. package/src/runtime/interactive-box.tsx +0 -57
@@ -0,0 +1,267 @@
1
+ import { createElement, useEffect, useRef, type ComponentType, type ReactElement } from 'react'
2
+ import { chainFocus, chainPress } from './chain-handlers'
3
+ import { useInteract } from './hooks/use-interact'
4
+ import { useRnwind } from './components/rnwind-provider'
5
+ import type { RnwindState } from './components/rnwind-provider'
6
+ import { resolve, type ResolvedCss } from './resolve'
7
+ import type { OnHaptics } from '../core/parser/haptics'
8
+
9
+ /** Matches a leading `active:` / `focus:` variant token (`\b` excludes `inactive:`). */
10
+ const INTERACTIVE_VARIANT = /\b(?:active|focus):/
11
+
12
+ /** One-shot guard so the missing-`onHaptics` warning logs once per session. */
13
+ let warnedMissingOnHaptics = false
14
+
15
+ /**
16
+ * Dev-only warning when a className carries a haptic utility but no
17
+ * `onHaptics` dispatcher is wired on the nearest `<RnwindProvider>` — the
18
+ * haptic would silently drop otherwise. Fires once per session.
19
+ * @param onHaptics The dispatcher from context (or undefined).
20
+ * @param haptics The resolved haptic requests (or undefined).
21
+ */
22
+ function warnIfHapticsUnwired(onHaptics: OnHaptics | undefined, haptics: ResolvedCss['haptics']): void {
23
+ if (onHaptics || !haptics || haptics.length === 0) return
24
+ const isDevelopment = typeof __DEV__ === 'undefined' || __DEV__
25
+ if (!isDevelopment || warnedMissingOnHaptics) return
26
+ warnedMissingOnHaptics = true
27
+ // eslint-disable-next-line no-console
28
+ console.warn(
29
+ 'rnwind: a `haptic-*` utility resolved but no `onHaptics` callback is wired on <RnwindProvider>. ' +
30
+ 'Pass `onHaptics` on the provider to forward the request to expo-haptics (or any library).',
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Whether a className needs press/focus state tracking.
36
+ * @param className Raw className string.
37
+ * @returns True when an `active:` / `focus:` variant is present.
38
+ */
39
+ function hasInteractiveVariant(className: string): boolean {
40
+ return INTERACTIVE_VARIANT.test(className)
41
+ }
42
+
43
+ /**
44
+ * Best-effort display name for the wrapped component.
45
+ * @param component Component being wrapped.
46
+ * @returns Its `displayName`, `name`, or `'Component'`.
47
+ */
48
+ function displayNameOf(component: unknown): string {
49
+ const named = component as { displayName?: string; name?: string }
50
+ return named.displayName ?? named.name ?? 'Component'
51
+ }
52
+
53
+ /**
54
+ * Fire the `mount`-trigger haptics once, after the element mounts. Snaps
55
+ * the resolved requests + dispatcher at mount via a `useRef` initializer
56
+ * (evaluated only on the first render), so an unstable inline `onHaptics`
57
+ * doesn't re-fire them and no ref is written during render.
58
+ * @param resolved The resolved className (carries any haptic requests).
59
+ * @param onHaptics The dispatcher from context (or undefined).
60
+ */
61
+ function useMountHaptics(resolved: ResolvedCss, onHaptics: OnHaptics | undefined): void {
62
+ const mount = useRef({ resolved, onHaptics })
63
+ useEffect(() => {
64
+ const { resolved: current, onHaptics: dispatch } = mount.current
65
+ if (!dispatch || !current.haptics) return
66
+ for (const entry of current.haptics) if (entry.trigger === 'mount') dispatch(entry.request, 'mount')
67
+ }, [])
68
+ }
69
+
70
+ /** Suffix marking a secondary class-prop (`contentContainerClassName`, …). */
71
+ const CLASSNAME_SUFFIX = 'ClassName'
72
+
73
+ /**
74
+ * Resolve every secondary `<prefix>ClassName` prop (e.g.
75
+ * `contentContainerClassName` on a ScrollView / FlatList) into its
76
+ * matching `<prefix>Style`, in place. Any existing `<prefix>Style` is
77
+ * appended last (caller wins). The original `*ClassName` prop is deleted
78
+ * so RN never sees an unknown attribute. The primary `className` is
79
+ * handled separately by the leaf and never reaches here.
80
+ * @param props Mutable prop object being assembled for the host.
81
+ * @param state Rnwind context for resolution.
82
+ */
83
+ function applyContainerClassNames(props: Record<string, unknown>, state: RnwindState): void {
84
+ for (const key of Object.keys(props)) {
85
+ if (!key.endsWith(CLASSNAME_SUFFIX)) continue
86
+ const value = props[key]
87
+ if (typeof value !== 'string') continue
88
+ const styleKey = `${key.slice(0, -CLASSNAME_SUFFIX.length)}Style`
89
+ props[styleKey] = resolve(value, state, props[styleKey]).style
90
+ delete props[key]
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Build the props for the wrapped host: resolved `style`, gradient
96
+ * (`colors`/`start`/`end`), truncate (`numberOfLines`/`ellipsizeMode`),
97
+ * secondary `<prefix>ClassName` → `<prefix>Style`, and a chained
98
+ * `onPressIn` that fires press-trigger haptics. Unknown props on a host
99
+ * are simply ignored by RN, so this stays generic.
100
+ * @param rest Forwarded props (incl. `ref`).
101
+ * @param resolved Resolved className result.
102
+ * @param state Rnwind context — used to resolve secondary class props.
103
+ * @param onHaptics Dispatcher from context.
104
+ * @param userOnPressIn Caller-supplied onPressIn to chain after the haptic.
105
+ * @returns The merged prop object for `createElement`.
106
+ */
107
+ function buildProps(
108
+ rest: Record<string, unknown>,
109
+ resolved: ResolvedCss,
110
+ state: RnwindState,
111
+ onHaptics: OnHaptics | undefined,
112
+ userOnPressIn?: unknown,
113
+ ): Record<string, unknown> {
114
+ warnIfHapticsUnwired(onHaptics, resolved.haptics)
115
+ const props: Record<string, unknown> = { ...rest, style: resolved.style }
116
+ applyContainerClassNames(props, state)
117
+ if (resolved.colors) {
118
+ props.colors = resolved.colors
119
+ props.start = resolved.start
120
+ props.end = resolved.end
121
+ }
122
+ if (resolved.numberOfLines !== undefined) {
123
+ props.numberOfLines = resolved.numberOfLines
124
+ if (resolved.ellipsizeMode !== undefined) props.ellipsizeMode = resolved.ellipsizeMode
125
+ }
126
+ const pressHaptics = onHaptics && resolved.haptics?.filter((entry) => entry.trigger === 'pressIn')
127
+ if (pressHaptics && pressHaptics.length > 0) {
128
+ const previous = userOnPressIn as ((event: unknown) => void) | undefined
129
+ props.onPressIn = (event: unknown): void => {
130
+ for (const entry of pressHaptics) onHaptics(entry.request, 'pressIn')
131
+ previous?.(event)
132
+ }
133
+ } else if (userOnPressIn !== undefined) {
134
+ props.onPressIn = userOnPressIn
135
+ }
136
+ return props
137
+ }
138
+
139
+ /** Props a leaf receives — the wrapped `as` tag plus forwarded props. */
140
+ interface LeafProps {
141
+ readonly as: ComponentType<Record<string, unknown>>
142
+ readonly className?: string
143
+ readonly style?: unknown
144
+ readonly [key: string]: unknown
145
+ }
146
+
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
+ /**
192
+ * Wrap a component so its `className` prop resolves to RN `style` (plus
193
+ * gradient / truncate props and haptic dispatch) at render — no matter
194
+ * 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.
199
+ * @example
200
+ * ```tsx
201
+ * const Pressable = wrap(RNPressable)
202
+ * <Pressable className="active:bg-sky-700 px-4 haptic-light" onPress={fn} />
203
+ * ```
204
+ * @param Component Any component accepting a `style` prop.
205
+ * @returns A component accepting `className`.
206
+ */
207
+ export function wrap<P>(Component: ComponentType<P>): ComponentType<P & { className?: string }> {
208
+ const as = Component as unknown as ComponentType<Record<string, unknown>>
209
+ /**
210
+ * The wrapped component — hook-free dispatcher to a leaf.
211
+ * @param props Forwarded props with `className` intercepted.
212
+ * @param props.className
213
+ * @returns The rendered leaf.
214
+ */
215
+ function RnwindWrapped({ className, ...rest }: { className?: string; [key: string]: unknown }): ReactElement {
216
+ if (className !== undefined && hasInteractiveVariant(className)) {
217
+ return createElement(InteractiveLeaf, { as, className, ...rest })
218
+ }
219
+ return createElement(PlainLeaf, { as, className, ...rest })
220
+ }
221
+ RnwindWrapped.displayName = `wrap(${displayNameOf(Component)})`
222
+ return RnwindWrapped as unknown as ComponentType<P & { className?: string }>
223
+ }
224
+
225
+ /**
226
+ * Whether a namespace member name denotes a component to wrap —
227
+ * PascalCase and not a React context (`*Context`). Lowercase utilities /
228
+ * hooks (`createAnimatedComponent`, `spring`) pass through untouched.
229
+ * @param name Member key.
230
+ * @returns True when the member should be `wrap()`-ed.
231
+ */
232
+ function isComponentMember(name: string): boolean {
233
+ return /^[A-Z]/.test(name) && !name.endsWith('Context')
234
+ }
235
+
236
+ /**
237
+ * Wrap a component NAMESPACE (a default/namespace import like reanimated's
238
+ * `Animated`) so member access — `Animated.View`, `Animated.ScrollView` —
239
+ * returns a `wrap()`-ed component whose `className` resolves at render.
240
+ * Returns a Proxy: component members are wrapped lazily and memoised so
241
+ * each access yields the SAME wrapped component (stable identity — React
242
+ * would remount otherwise). Non-component members (`createAnimatedComponent`,
243
+ * config objects) pass straight through.
244
+ * @example
245
+ * ```tsx
246
+ * const Animated = wrapNamespace(RNReanimated)
247
+ * <Animated.View className="enter-fade" />
248
+ * ```
249
+ * @param namespace The imported namespace object.
250
+ * @returns A Proxy that wraps component members on access.
251
+ */
252
+ export function wrapNamespace<T extends object>(namespace: T): T {
253
+ const cache = new Map<string, unknown>()
254
+ return new Proxy(namespace, {
255
+ get(target, key, receiver): unknown {
256
+ const value = Reflect.get(target, key, receiver)
257
+ if (typeof key !== 'string' || !isComponentMember(key)) return value
258
+ if (!value || (typeof value !== 'function' && typeof value !== 'object')) return value
259
+ let wrapped = cache.get(key)
260
+ if (wrapped === undefined) {
261
+ wrapped = wrap(value as ComponentType<unknown>)
262
+ cache.set(key, wrapped)
263
+ }
264
+ return wrapped
265
+ },
266
+ })
267
+ }
@@ -2,7 +2,7 @@
2
2
  import type { File as BabelFile } from '@babel/types'
3
3
  import { parse } from '@babel/parser'
4
4
  import generateImport from '@babel/generator'
5
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
6
6
  import { createRequire } from 'node:module'
7
7
  import { tmpdir } from 'node:os'
8
8
  import path from 'node:path'
@@ -13,10 +13,16 @@ import type {
13
13
  RenderHookOptions,
14
14
  } from '@testing-library/react-native'
15
15
  import { configureRnwindState, resetRnwindState, transform as metroTransform } from '../metro'
16
- import { __resetLookupCssState, lookupCss, registerAtoms } from '../runtime/lookup-css'
16
+ import {
17
+ __resetLookupCssState,
18
+ registerAtoms,
19
+ registerBreakpoints,
20
+ registerSchemeLoader,
21
+ } from '../runtime/lookup-css'
22
+ import { __resetResolveState, registerGradients, registerHaptics, registerMolecules } from '../runtime/resolve'
23
+ import * as rnwindRuntime from '../runtime'
17
24
  import type { Insets } from '../runtime/components/rnwind-provider'
18
- import { RnwindProvider, useRnwind, useR_ } from '../runtime/components/rnwind-provider'
19
- import { triggerHaptic, useMountHaptic } from '../runtime/haptics'
25
+ import { RnwindProvider } from '../runtime/components/rnwind-provider'
20
26
  import type { OnHaptics } from '../core/parser/haptics'
21
27
  import type { Scheme } from '../runtime/types'
22
28
 
@@ -41,29 +47,6 @@ const DEFAULT_THEME_CSS = `@import 'tailwindcss';
41
47
  /** `StyleSheet.create` stub the evaluated bundle calls — identity at test time. */
42
48
  const BUNDLE_STYLE_SHEET = { create: <T>(styles: T): T => styles, hairlineWidth: 1 }
43
49
 
44
- /** Stubbed interactive-state hook return matching `useInteract()`. */
45
- const NOOP_INTERACT = {
46
- state: undefined,
47
- onPressIn: (): void => undefined,
48
- onPressOut: (): void => undefined,
49
- onFocus: (): void => undefined,
50
- onBlur: (): void => undefined,
51
- }
52
-
53
- /**
54
- * Identity chain helper matching `chainPress` / `chainFocus`. Returns a
55
- * single callback that invokes every supplied handler in turn.
56
- * @param handlers Handlers to chain.
57
- * @returns Combined callback.
58
- */
59
- const NOOP_CHAIN =
60
- (...handlers: ReadonlyArray<((...args: unknown[]) => unknown) | undefined>) =>
61
- (...args: unknown[]): void => {
62
- for (const handler of handlers) {
63
- if (typeof handler === 'function') handler(...args)
64
- }
65
- }
66
-
67
50
  // Synthesize a require rooted at the consumer's cwd so optional peer
68
51
  // lookups (`@testing-library/react-native`, `esbuild`) resolve from THEIR
69
52
  // node_modules, not rnwind's. A workspace-local rnwind install resolves
@@ -149,28 +132,104 @@ function compileToJs(source: string): string {
149
132
  }
150
133
 
151
134
  /**
152
- * Evaluate the union `style.js` the ledger wrote during the transform
153
- * so its `registerAtoms` call lands in the process-global registry.
154
- * Strips the `react-native` + `rnwind` imports from the source and
155
- * forwards local bindings into the evaluated closure.
156
- * @param cacheDir The rnwind cache dir where the ledger writes bundles.
135
+ * Evaluate one generated registry file (`common.style.js`,
136
+ * `<variant>.style.js`, or `schemes.js`) so its `registerAtoms` /
137
+ * `registerBreakpoints` / `registerGradients` / `registerHaptics` /
138
+ * `registerSchemeLoader` calls land in the process-global registries the
139
+ * runtime resolver reads. Imports (`'rnwind'`, `'react-native'`, relative
140
+ * `./common.style`), `require(...)` loaders, and `export {...}` are
141
+ * stripped — every scheme file is evaluated directly, so lazy loaders are
142
+ * inert.
143
+ * @param filePath Absolute path to the generated file.
157
144
  */
158
- function evaluateStyleBundle(cacheDir: string): void {
159
- const filePath = path.join(cacheDir, 'style.js')
145
+ function evaluateGeneratedFile(filePath: string): void {
160
146
  if (!existsSync(filePath)) return
161
147
  const body = readFileSync(filePath, 'utf8')
162
- .replaceAll(/import \{ StyleSheet \} from 'react-native'\s*\n/g, '')
163
- .replaceAll(/import \{[^}]+\} from 'rnwind'\s*\n/g, '')
164
- // Bundle body is generated by rnwind itself — not user-controlled.
148
+ .replaceAll(/import\s+\{[^}]*\}\s+from\s+['"][^'"]+['"];?\s*\n?/g, '')
149
+ .replaceAll(/import\s+['"][^'"]+['"];?\s*\n?/g, '')
150
+ .replaceAll(/export\s+\{[^}]*\}\s*;?\s*\n?/g, '')
151
+ // Generated body is produced by rnwind itself — not user-controlled.
152
+ // `require` is neutralised: each variant file is evaluated directly,
153
+ // so the manifest's lazy `require('./x.style')` loaders are no-ops.
165
154
  // eslint-disable-next-line sonarjs/code-eval
166
- new Function('StyleSheet', 'registerAtoms', body)(BUNDLE_STYLE_SHEET, registerAtoms)
155
+ new Function(
156
+ 'StyleSheet',
157
+ 'registerAtoms',
158
+ 'registerMolecules',
159
+ 'registerBreakpoints',
160
+ 'registerGradients',
161
+ 'registerHaptics',
162
+ 'registerSchemeLoader',
163
+ 'require',
164
+ body,
165
+ )(
166
+ BUNDLE_STYLE_SHEET,
167
+ registerAtoms,
168
+ registerMolecules,
169
+ registerBreakpoints,
170
+ registerGradients,
171
+ registerHaptics,
172
+ registerSchemeLoader,
173
+ () => {},
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Sort comparator that floats `common.style.js` to the front, rest alpha.
179
+ * @param a
180
+ * @param b
181
+ */
182
+ function commonFirst(a: string, b: string): number {
183
+ if (a === 'common.style.js') return -1
184
+ if (b === 'common.style.js') return 1
185
+ return a.localeCompare(b)
186
+ }
187
+
188
+ /**
189
+ * Evaluate every generated registry the transform wrote (`common` +
190
+ * every variant scheme + the manifest) so the runtime resolver sees the
191
+ * full atom / molecule / gradient / haptic registries — same state a
192
+ * production bundle would hold once all scheme files load.
193
+ * @param cacheDir The rnwind cache dir holding the generated files.
194
+ */
195
+ function evaluateGeneratedRegistries(cacheDir: string): void {
196
+ if (!existsSync(cacheDir)) return
197
+ const files = readdirSync(cacheDir).filter((name) => name.endsWith('.style.js'))
198
+ // common first so variants layer their diffs on top of it.
199
+ for (const name of files.toSorted(commonFirst)) {
200
+ evaluateGeneratedFile(path.join(cacheDir, name))
201
+ }
202
+ evaluateGeneratedFile(path.join(cacheDir, 'schemes.js'))
203
+ }
204
+
205
+ /**
206
+ * Move every `const {…} = __rnwind;` / `const {…} = __reactNative;`
207
+ * binding line to the top of the source, preserving order, so they
208
+ * initialise before the transformer's `const View = _rnwWrap(_rnw0)`
209
+ * wrap declarations reference them. Mirrors ESM import hoisting.
210
+ * @param source Source with imports already converted to const-destructures.
211
+ * @returns Source with binding consts hoisted to the front.
212
+ */
213
+ function hoistBindingConsts(source: string): string {
214
+ const hoisted: string[] = []
215
+ const rest: string[] = []
216
+ for (const line of source.split('\n')) {
217
+ if (/=\s*__(?:rnwind|reactNative);\s*$/.test(line)) hoisted.push(line)
218
+ else rest.push(line)
219
+ }
220
+ return [...hoisted, ...rest].join('\n')
167
221
  }
168
222
 
169
223
  /**
170
224
  * Evaluate the transformer's rewritten source as a standalone module:
171
- * strip synthetic imports, forward `import ... from 'rnwind'` +
172
- * `'react-native'` to local bindings, compile JSX via the compiler, and
173
- * capture the default export.
225
+ * strip synthetic generated imports, forward `import ... from 'rnwind'`
226
+ * (incl. the injected `wrap as _rnwWrap`) and `'react-native'` to local
227
+ * bindings, compile JSX via the compiler, and capture the default export.
228
+ * Aliased named imports (`{ View as _rnw0 }`) are converted to valid
229
+ * destructuring (`{ View: _rnw0 }`). The import-derived `const`s are then
230
+ * hoisted above the body: the transformer injects `const View =
231
+ * _rnwWrap(_rnw0)` ahead of the (real-ESM-hoisted) `import … as _rnw0`,
232
+ * so without re-hoisting the binding the eval would hit a TDZ on `_rnw0`.
174
233
  * @param transformedSource Post-transformer source.
175
234
  * @param reactNative `react-native` namespace bindings to forward.
176
235
  * @returns The default-exported component.
@@ -181,25 +240,15 @@ function evaluateRewrittenModule(
181
240
  ): React.ComponentType<Record<string, unknown>> {
182
241
  const prepared = transformedSource
183
242
  .replaceAll(/import\s+["']rnwind\/__generated\/[^"']+["'];?\s*\n?/g, '')
184
- .replaceAll(/import\s+\{([^}]+)\}\s+from\s+["']rnwind["'];?/g, 'const {$1} = __rnwind;')
185
- .replaceAll(/import\s+\{([^}]+)\}\s+from\s+["']react-native["'];?/g, 'const {$1} = __reactNative;')
243
+ .replaceAll(/import\s+\{([^}]+)\}\s+from\s+["']rnwind["'];?/g, (_m, spec: string) => `const {${spec.replaceAll(/\bas\b/g, ':')}} = __rnwind;`)
244
+ .replaceAll(/import\s+\{([^}]+)\}\s+from\s+["']react-native["'];?/g, (_m, spec: string) => `const {${spec.replaceAll(/\bas\b/g, ':')}} = __reactNative;`)
186
245
  .replace(/export\s+default\s+/, 'module.exports.default = ')
187
246
 
188
- const compiled = compileToJs(prepared)
189
- const rnwindEnv = {
190
- lookupCss,
191
- useRnwind,
192
- useR_,
193
- useMountHaptic,
194
- triggerHaptic,
195
- useInteract: () => NOOP_INTERACT,
196
- chainPress: NOOP_CHAIN,
197
- chainFocus: NOOP_CHAIN,
198
- }
247
+ const compiled = compileToJs(hoistBindingConsts(prepared))
199
248
  const moduleObject: { exports: { default?: React.ComponentType<Record<string, unknown>> } } = { exports: {} }
200
249
  // Compiled source originates from rnwind's own transformer + the JSX compiler.
201
250
  // eslint-disable-next-line sonarjs/code-eval
202
- new Function('React', '__rnwind', '__reactNative', 'module', compiled)(React, rnwindEnv, reactNative, moduleObject)
251
+ new Function('React', '__rnwind', '__reactNative', 'module', compiled)(React, rnwindRuntime, reactNative, moduleObject)
203
252
  if (!moduleObject.exports.default) {
204
253
  throw new Error('rnwind/testing: evaluated module did not export a default component.')
205
254
  }
@@ -287,11 +336,12 @@ async function bootstrapRnwindRuntime(options: { themeCss?: string; source?: str
287
336
  const ast: BabelFile = parse(options.source, { sourceType: 'module', plugins: ['typescript', 'jsx'] })
288
337
  const result = await metroTransform({ filename, src: options.source, options: { projectRoot }, ast })
289
338
  transformedSource = generate(result.ast).code
290
- evaluateStyleBundle(cacheDir)
339
+ evaluateGeneratedRegistries(cacheDir)
291
340
  }
292
341
 
293
342
  const cleanup = (): void => {
294
343
  __resetLookupCssState()
344
+ __resetResolveState()
295
345
  resetRnwindState()
296
346
  if (existsSync(projectRoot)) rmSync(projectRoot, { recursive: true, force: true })
297
347
  }
@@ -1,78 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Text-truncate atom detector.
5
- *
6
- * React Native's `<Text>` exposes two props that cover every case
7
- * Tailwind's text-truncation utilities express:
8
- *
9
- * numberOfLines — clamp after N lines (1 for single-line ellipsis)
10
- * ellipsizeMode — 'tail' (ellipsis) or 'clip' at the truncation point
11
- *
12
- * rnwind treats the Tailwind classes as **metadata**: the transformer
13
- * strips the truncate atoms from the JSX site's className and emits
14
- * `numberOfLines={N}` / `ellipsizeMode="tail"` props on the element.
15
- *
16
- * Covered atoms:
17
- * truncate → { numberOfLines: 1, ellipsizeMode: 'tail' }
18
- * text-ellipsis → { ellipsizeMode: 'tail' }
19
- * text-clip → { ellipsizeMode: 'clip' }
20
- * line-clamp-<N> → { numberOfLines: <N> }
21
- * line-clamp-none → { numberOfLines: 0 } (reset — overrides prior)
22
- *
23
- * Detection is pure name matching — no CSS inspection — because every
24
- * relevant piece of data lives in the class name itself.
25
- */
26
- /** Regex matching `line-clamp-<N>` with a positive-integer N. */
27
- const LINE_CLAMP_RE = /^line-clamp-(\d+)$/;
28
- /** Regex matching Tailwind v4's `line-clamp-[<value>]` arbitrary form. */
29
- const LINE_CLAMP_ARBITRARY_RE = /^line-clamp-\[([^\]]+)\]$/;
30
- /**
31
- * Inspect one class-name token and return the text-truncate metadata it
32
- * contributes, or `null` when the atom isn't a truncate utility.
33
- * @param atom Single class-name token (no variant prefix).
34
- * @returns The atom's contribution, or null.
35
- */
36
- function detectTextTruncate(atom) {
37
- if (atom === 'truncate')
38
- return { numberOfLines: 1, ellipsizeMode: 'tail' };
39
- if (atom === 'text-ellipsis')
40
- return { ellipsizeMode: 'tail' };
41
- if (atom === 'text-clip')
42
- return { ellipsizeMode: 'clip' };
43
- if (atom === 'line-clamp-none')
44
- return { numberOfLines: 0 };
45
- const numeric = LINE_CLAMP_RE.exec(atom);
46
- if (numeric)
47
- return { numberOfLines: Number(numeric[1]) };
48
- const arbitrary = LINE_CLAMP_ARBITRARY_RE.exec(atom);
49
- if (arbitrary) {
50
- const n = Number.parseInt(arbitrary[1], 10);
51
- if (Number.isFinite(n) && n >= 0)
52
- return { numberOfLines: n };
53
- }
54
- return null;
55
- }
56
- /**
57
- * Fast pre-check — returns true when ANY atom in the list could be a
58
- * truncate utility, false when none can. Lets callers skip the
59
- * allocation of the merge pass for the common "no truncate" case.
60
- * @param atoms Tokenised atom list from a literal className.
61
- * @returns Whether to run the full per-atom detection.
62
- */
63
- function mayContainTextTruncate(atoms) {
64
- for (const atom of atoms) {
65
- if (atom === 'truncate' ||
66
- atom === 'text-ellipsis' ||
67
- atom === 'text-clip' ||
68
- atom === 'line-clamp-none' ||
69
- atom.startsWith('line-clamp-')) {
70
- return true;
71
- }
72
- }
73
- return false;
74
- }
75
-
76
- exports.detectTextTruncate = detectTextTruncate;
77
- exports.mayContainTextTruncate = mayContainTextTruncate;
78
- //# sourceMappingURL=text-truncate.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"text-truncate.cjs","sources":["../../../../../src/core/parser/text-truncate.ts"],"sourcesContent":["/**\n * Text-truncate atom detector.\n *\n * React Native's `<Text>` exposes two props that cover every case\n * Tailwind's text-truncation utilities express:\n *\n * numberOfLines — clamp after N lines (1 for single-line ellipsis)\n * ellipsizeMode — 'tail' (ellipsis) or 'clip' at the truncation point\n *\n * rnwind treats the Tailwind classes as **metadata**: the transformer\n * strips the truncate atoms from the JSX site's className and emits\n * `numberOfLines={N}` / `ellipsizeMode=\"tail\"` props on the element.\n *\n * Covered atoms:\n * truncate → { numberOfLines: 1, ellipsizeMode: 'tail' }\n * text-ellipsis → { ellipsizeMode: 'tail' }\n * text-clip → { ellipsizeMode: 'clip' }\n * line-clamp-<N> → { numberOfLines: <N> }\n * line-clamp-none → { numberOfLines: 0 } (reset — overrides prior)\n *\n * Detection is pure name matching — no CSS inspection — because every\n * relevant piece of data lives in the class name itself.\n */\n\n/** Text-truncate metadata produced by a single atom. */\nexport interface TextTruncateInfo {\n readonly numberOfLines?: number\n readonly ellipsizeMode?: 'tail' | 'clip'\n}\n\n/** Regex matching `line-clamp-<N>` with a positive-integer N. */\nconst LINE_CLAMP_RE = /^line-clamp-(\\d+)$/\n/** Regex matching Tailwind v4's `line-clamp-[<value>]` arbitrary form. */\nconst LINE_CLAMP_ARBITRARY_RE = /^line-clamp-\\[([^\\]]+)\\]$/\n\n/**\n * Inspect one class-name token and return the text-truncate metadata it\n * contributes, or `null` when the atom isn't a truncate utility.\n * @param atom Single class-name token (no variant prefix).\n * @returns The atom's contribution, or null.\n */\nfunction detectTextTruncate(atom: string): TextTruncateInfo | null {\n if (atom === 'truncate') return { numberOfLines: 1, ellipsizeMode: 'tail' }\n if (atom === 'text-ellipsis') return { ellipsizeMode: 'tail' }\n if (atom === 'text-clip') return { ellipsizeMode: 'clip' }\n if (atom === 'line-clamp-none') return { numberOfLines: 0 }\n const numeric = LINE_CLAMP_RE.exec(atom)\n if (numeric) return { numberOfLines: Number(numeric[1]) }\n const arbitrary = LINE_CLAMP_ARBITRARY_RE.exec(atom)\n if (arbitrary) {\n const n = Number.parseInt(arbitrary[1]!, 10)\n if (Number.isFinite(n) && n >= 0) return { numberOfLines: n }\n }\n return null\n}\n\n/**\n * Fast pre-check — returns true when ANY atom in the list could be a\n * truncate utility, false when none can. Lets callers skip the\n * allocation of the merge pass for the common \"no truncate\" case.\n * @param atoms Tokenised atom list from a literal className.\n * @returns Whether to run the full per-atom detection.\n */\nfunction mayContainTextTruncate(atoms: readonly string[]): boolean {\n for (const atom of atoms) {\n if (\n atom === 'truncate' ||\n atom === 'text-ellipsis' ||\n atom === 'text-clip' ||\n atom === 'line-clamp-none' ||\n atom.startsWith('line-clamp-')\n ) {\n return true\n }\n }\n return false\n}\n\nexport { detectTextTruncate, mayContainTextTruncate }\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;;;;;;AAsBG;AAQH;AACA,MAAM,aAAa,GAAG,oBAAoB;AAC1C;AACA,MAAM,uBAAuB,GAAG,2BAA2B;AAE3D;;;;;AAKG;AACH,SAAS,kBAAkB,CAAC,IAAY,EAAA;IACtC,IAAI,IAAI,KAAK,UAAU;QAAE,OAAO,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,EAAE,MAAM,EAAE;IAC3E,IAAI,IAAI,KAAK,eAAe;AAAE,QAAA,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE;IAC9D,IAAI,IAAI,KAAK,WAAW;AAAE,QAAA,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE;IAC1D,IAAI,IAAI,KAAK,iBAAiB;AAAE,QAAA,OAAO,EAAE,aAAa,EAAE,CAAC,EAAE;IAC3D,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC;AACxC,IAAA,IAAI,OAAO;QAAE,OAAO,EAAE,aAAa,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE;IACzD,MAAM,SAAS,GAAG,uBAAuB,CAAC,IAAI,CAAC,IAAI,CAAC;IACpD,IAAI,SAAS,EAAE;AACb,QAAA,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAE,EAAE,EAAE,CAAC;QAC5C,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;AAAE,YAAA,OAAO,EAAE,aAAa,EAAE,CAAC,EAAE;IAC/D;AACA,IAAA,OAAO,IAAI;AACb;AAEA;;;;;;AAMG;AACH,SAAS,sBAAsB,CAAC,KAAwB,EAAA;AACtD,IAAA,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE;QACxB,IACE,IAAI,KAAK,UAAU;AACnB,YAAA,IAAI,KAAK,eAAe;AACxB,YAAA,IAAI,KAAK,WAAW;AACpB,YAAA,IAAI,KAAK,iBAAiB;AAC1B,YAAA,IAAI,CAAC,UAAU,CAAC,aAAa,CAAC,EAC9B;AACA,YAAA,OAAO,IAAI;QACb;IACF;AACA,IAAA,OAAO,KAAK;AACd;;;;;"}