@tamagui/web 2.3.3 → 2.4.0-1783099846627

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 (108) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cjs/createComponent.cjs +4 -3
  3. package/dist/cjs/createComponent.native.js +7 -6
  4. package/dist/cjs/createComponent.native.js.map +1 -1
  5. package/dist/cjs/eventHandling.cjs +1 -1
  6. package/dist/cjs/eventHandling.native.js +12 -36
  7. package/dist/cjs/eventHandling.native.js.map +1 -1
  8. package/dist/cjs/helpers/getSplitStyles.cjs +5 -0
  9. package/dist/cjs/helpers/getSplitStyles.native.js +9 -0
  10. package/dist/cjs/helpers/getSplitStyles.native.js.map +1 -1
  11. package/dist/cjs/helpers/mainThreadPressEvents.native.js +20 -3
  12. package/dist/cjs/helpers/mainThreadPressEvents.native.js.map +1 -1
  13. package/dist/cjs/helpers/pointerEvents.native.js +26 -20
  14. package/dist/cjs/helpers/pointerEvents.native.js.map +1 -1
  15. package/dist/cjs/hooks/useComponentState.cjs +25 -1
  16. package/dist/cjs/hooks/useComponentState.native.js +25 -1
  17. package/dist/cjs/hooks/useComponentState.native.js.map +1 -1
  18. package/dist/cjs/hooks/useMedia.cjs +107 -42
  19. package/dist/cjs/hooks/useMedia.native.js +149 -60
  20. package/dist/cjs/hooks/useMedia.native.js.map +1 -1
  21. package/dist/cjs/hooks/useTheme.cjs +17 -4
  22. package/dist/cjs/hooks/useTheme.native.js +18 -4
  23. package/dist/cjs/hooks/useTheme.native.js.map +1 -1
  24. package/dist/cjs/hooks/useThemeState.cjs +132 -67
  25. package/dist/cjs/hooks/useThemeState.native.js +144 -70
  26. package/dist/cjs/hooks/useThemeState.native.js.map +1 -1
  27. package/dist/cjs/index.cjs +2 -0
  28. package/dist/cjs/index.native.js +2 -0
  29. package/dist/cjs/index.native.js.map +1 -1
  30. package/dist/cjs/views/Theme.cjs +1 -1
  31. package/dist/cjs/views/Theme.native.js +1 -1
  32. package/dist/cjs/views/Theme.native.js.map +1 -1
  33. package/dist/esm/createComponent.mjs +4 -3
  34. package/dist/esm/createComponent.mjs.map +1 -1
  35. package/dist/esm/createComponent.native.js +10 -8
  36. package/dist/esm/createComponent.native.js.map +1 -1
  37. package/dist/esm/eventHandling.mjs +1 -1
  38. package/dist/esm/eventHandling.mjs.map +1 -1
  39. package/dist/esm/eventHandling.native.js +12 -36
  40. package/dist/esm/eventHandling.native.js.map +1 -1
  41. package/dist/esm/helpers/getSplitStyles.mjs +5 -0
  42. package/dist/esm/helpers/getSplitStyles.mjs.map +1 -1
  43. package/dist/esm/helpers/getSplitStyles.native.js +9 -0
  44. package/dist/esm/helpers/getSplitStyles.native.js.map +1 -1
  45. package/dist/esm/helpers/mainThreadPressEvents.native.js +20 -3
  46. package/dist/esm/helpers/mainThreadPressEvents.native.js.map +1 -1
  47. package/dist/esm/helpers/pointerEvents.native.js +26 -20
  48. package/dist/esm/helpers/pointerEvents.native.js.map +1 -1
  49. package/dist/esm/hooks/useComponentState.mjs +26 -2
  50. package/dist/esm/hooks/useComponentState.mjs.map +1 -1
  51. package/dist/esm/hooks/useComponentState.native.js +26 -2
  52. package/dist/esm/hooks/useComponentState.native.js.map +1 -1
  53. package/dist/esm/hooks/useMedia.mjs +108 -43
  54. package/dist/esm/hooks/useMedia.mjs.map +1 -1
  55. package/dist/esm/hooks/useMedia.native.js +150 -61
  56. package/dist/esm/hooks/useMedia.native.js.map +1 -1
  57. package/dist/esm/hooks/useTheme.mjs +17 -4
  58. package/dist/esm/hooks/useTheme.mjs.map +1 -1
  59. package/dist/esm/hooks/useTheme.native.js +18 -4
  60. package/dist/esm/hooks/useTheme.native.js.map +1 -1
  61. package/dist/esm/hooks/useThemeState.mjs +133 -68
  62. package/dist/esm/hooks/useThemeState.mjs.map +1 -1
  63. package/dist/esm/hooks/useThemeState.native.js +145 -71
  64. package/dist/esm/hooks/useThemeState.native.js.map +1 -1
  65. package/dist/esm/index.js +3 -3
  66. package/dist/esm/index.js.map +1 -1
  67. package/dist/esm/index.mjs +3 -3
  68. package/dist/esm/index.mjs.map +1 -1
  69. package/dist/esm/index.native.js +3 -3
  70. package/dist/esm/index.native.js.map +1 -1
  71. package/dist/esm/views/Theme.mjs +1 -1
  72. package/dist/esm/views/Theme.mjs.map +1 -1
  73. package/dist/esm/views/Theme.native.js +1 -1
  74. package/dist/esm/views/Theme.native.js.map +1 -1
  75. package/package.json +13 -13
  76. package/src/createComponent.tsx +18 -7
  77. package/src/eventHandling.native.ts +28 -40
  78. package/src/eventHandling.ts +2 -1
  79. package/src/helpers/getSplitStyles.tsx +33 -0
  80. package/src/helpers/mainThreadPressEvents.native.ts +25 -3
  81. package/src/helpers/pointerEvents.native.ts +26 -19
  82. package/src/hooks/useComponentState.ts +39 -2
  83. package/src/hooks/useMedia.tsx +147 -42
  84. package/src/hooks/useTheme.tsx +27 -5
  85. package/src/hooks/useThemeState.ts +263 -137
  86. package/src/index.ts +7 -1
  87. package/src/types.tsx +53 -5
  88. package/src/views/Theme.tsx +4 -1
  89. package/types/createComponent.d.ts.map +1 -1
  90. package/types/eventHandling.d.ts +1 -1
  91. package/types/eventHandling.d.ts.map +1 -1
  92. package/types/eventHandling.native.d.ts +2 -2
  93. package/types/eventHandling.native.d.ts.map +1 -1
  94. package/types/helpers/getSplitStyles.d.ts.map +1 -1
  95. package/types/helpers/mainThreadPressEvents.native.d.ts.map +1 -1
  96. package/types/helpers/pointerEvents.native.d.ts.map +1 -1
  97. package/types/hooks/useComponentState.d.ts +1 -1
  98. package/types/hooks/useComponentState.d.ts.map +1 -1
  99. package/types/hooks/useMedia.d.ts.map +1 -1
  100. package/types/hooks/useTheme.d.ts +1 -1
  101. package/types/hooks/useTheme.d.ts.map +1 -1
  102. package/types/hooks/useThemeState.d.ts +1 -1
  103. package/types/hooks/useThemeState.d.ts.map +1 -1
  104. package/types/index.d.ts +2 -2
  105. package/types/index.d.ts.map +1 -1
  106. package/types/types.d.ts +3 -1
  107. package/types/types.d.ts.map +1 -1
  108. package/types/views/Theme.d.ts.map +1 -1
@@ -25,13 +25,20 @@ export function usePointerEvents(props: any, viewProps: any) {
25
25
  onPointerEnter ||
26
26
  onPointerLeave
27
27
 
28
- // track if pointer is currently inside bounds (for enter/leave)
29
- const isInsideRef = useRef(false)
30
- const layoutRef = useRef({ width: 0, height: 0 })
31
- // track if pointer is captured (for move events outside bounds)
32
- const isCapturedRef = useRef(false)
28
+ // single ref-bag (was three separate useRef slots) one hook slot per
29
+ // component instead of three. isInside = pointer within bounds (enter/leave),
30
+ // layout = measured bounds, isCaptured = setPointerCapture state for moves.
31
+ const ref = useRef<{
32
+ isInside: boolean
33
+ layout: { width: number; height: number }
34
+ isCaptured: boolean
35
+ } | null>(null)
36
+ if (!ref.current) {
37
+ ref.current = { isInside: false, layout: { width: 0, height: 0 }, isCaptured: false }
38
+ }
33
39
 
34
40
  if (!hasPointerEvents) return
41
+ const bag = ref.current
35
42
 
36
43
  // create normalized event with setPointerCapture support
37
44
  const createNormalizedEvent = (e: any) => {
@@ -49,10 +56,10 @@ export function usePointerEvents(props: any, viewProps: any) {
49
56
  nativeEvent: touch,
50
57
  target: {
51
58
  setPointerCapture: (_pointerId: number) => {
52
- isCapturedRef.current = true
59
+ bag.isCaptured = true
53
60
  },
54
61
  releasePointerCapture: (_pointerId: number) => {
55
- isCapturedRef.current = false
62
+ bag.isCaptured = false
56
63
  },
57
64
  },
58
65
  }
@@ -69,7 +76,7 @@ export function usePointerEvents(props: any, viewProps: any) {
69
76
  // pointer up
70
77
  if (onPointerUp) {
71
78
  viewProps.onTouchEnd = composeEventHandlers(viewProps.onTouchEnd, (e: any) => {
72
- isCapturedRef.current = false // auto-release on up
79
+ bag.isCaptured = false // auto-release on up
73
80
  onPointerUp(createNormalizedEvent(e))
74
81
  })
75
82
  }
@@ -78,12 +85,12 @@ export function usePointerEvents(props: any, viewProps: any) {
78
85
  if (onPointerMove) {
79
86
  viewProps.onTouchMove = composeEventHandlers(viewProps.onTouchMove, (e: any) => {
80
87
  const { locationX, locationY } = e.nativeEvent
81
- const { width, height } = layoutRef.current
88
+ const { width, height } = bag.layout
82
89
  const isInBounds =
83
90
  locationX >= 0 && locationX <= width && locationY >= 0 && locationY <= height
84
91
 
85
92
  // fire if captured OR in bounds (matches web behavior)
86
- if (isCapturedRef.current || isInBounds) {
93
+ if (bag.isCaptured || isInBounds) {
87
94
  onPointerMove(createNormalizedEvent(e))
88
95
  }
89
96
  })
@@ -92,7 +99,7 @@ export function usePointerEvents(props: any, viewProps: any) {
92
99
  // pointer cancel
93
100
  if (onPointerCancel) {
94
101
  viewProps.onTouchCancel = composeEventHandlers(viewProps.onTouchCancel, (e: any) => {
95
- isCapturedRef.current = false
102
+ bag.isCaptured = false
96
103
  onPointerCancel(createNormalizedEvent(e))
97
104
  })
98
105
  }
@@ -101,7 +108,7 @@ export function usePointerEvents(props: any, viewProps: any) {
101
108
  if (onPointerEnter || onPointerLeave || onPointerMove) {
102
109
  // track layout for bounds checking
103
110
  viewProps.onLayout = composeEventHandlers(viewProps.onLayout, (e: any) => {
104
- layoutRef.current = {
111
+ bag.layout = {
105
112
  width: e.nativeEvent.layout.width,
106
113
  height: e.nativeEvent.layout.height,
107
114
  }
@@ -112,9 +119,9 @@ export function usePointerEvents(props: any, viewProps: any) {
112
119
  if (onPointerEnter) {
113
120
  viewProps.onTouchStart = composeEventHandlers(viewProps.onTouchStart, (e: any) => {
114
121
  const { locationX, locationY } = e.nativeEvent
115
- const { width, height } = layoutRef.current
122
+ const { width, height } = bag.layout
116
123
  if (locationX >= 0 && locationX <= width && locationY >= 0 && locationY <= height) {
117
- isInsideRef.current = true
124
+ bag.isInside = true
118
125
  onPointerEnter(createNormalizedEvent(e))
119
126
  }
120
127
  })
@@ -124,18 +131,18 @@ export function usePointerEvents(props: any, viewProps: any) {
124
131
  if (onPointerLeave) {
125
132
  viewProps.onTouchMove = composeEventHandlers(viewProps.onTouchMove, (e: any) => {
126
133
  const { locationX, locationY } = e.nativeEvent
127
- const { width, height } = layoutRef.current
134
+ const { width, height } = bag.layout
128
135
  const isInside =
129
136
  locationX >= 0 && locationX <= width && locationY >= 0 && locationY <= height
130
- if (isInsideRef.current && !isInside) {
131
- isInsideRef.current = false
137
+ if (bag.isInside && !isInside) {
138
+ bag.isInside = false
132
139
  onPointerLeave(createNormalizedEvent(e))
133
140
  }
134
141
  })
135
142
 
136
143
  viewProps.onTouchEnd = composeEventHandlers(viewProps.onTouchEnd, (e: any) => {
137
- if (isInsideRef.current) {
138
- isInsideRef.current = false
144
+ if (bag.isInside) {
145
+ bag.isInside = false
139
146
  onPointerLeave(createNormalizedEvent(e))
140
147
  }
141
148
  })
@@ -1,5 +1,5 @@
1
1
  import { getPlatformDriver, isServer, isWeb } from '@tamagui/constants'
2
- import { useCreateShallowSetState } from '@tamagui/is-equal-shallow'
2
+ import { mergeIfNotShallowEqual } from '@tamagui/is-equal-shallow'
3
3
  import { useDidFinishSSR, useIsClientOnly } from '@tamagui/use-did-finish-ssr'
4
4
  import { useRef, useState } from 'react'
5
5
  import { getSetting } from '../config'
@@ -176,7 +176,44 @@ export const useComponentState = (
176
176
 
177
177
  const groupName = props.group as any as string | undefined
178
178
 
179
- const setStateShallow = useCreateShallowSetState(setState, props.debug)
179
+ // hoisted shallow-set closure: created once per component instance and
180
+ // reused every render. drops the useCallback hook that useCreateShallowSetState
181
+ // would otherwise add. setState from useState is stable per instance so we
182
+ // can safely capture it. debug is read off stateRef at call time.
183
+ //
184
+ // important: this lives on `baseSetStateShallow`, not `setStateShallow`.
185
+ // createComponent's avoidReRenders path overwrites `stateRef.current.setStateShallow`
186
+ // with an emitter wrapper and captures this base as its real-re-render escape hatch.
187
+ // if the base shared the `setStateShallow` field, on the 2nd+ render this hook would
188
+ // read back the wrapper, the wrapper's escape hatch would point at itself, and a real
189
+ // re-render (e.g. unmounted 'should-enter' -> false) would never reach React, leaving
190
+ // enter animations stuck at opacity 0.
191
+ if (!stateRef.current.baseSetStateShallow) {
192
+ const r = stateRef.current
193
+ r.baseSetStateShallow = (stateOrGetState: any) => {
194
+ setState((prev: any) => {
195
+ const next =
196
+ typeof stateOrGetState === 'function' ? stateOrGetState(prev) : stateOrGetState
197
+ const update = mergeIfNotShallowEqual(prev, next)
198
+ if (process.env.NODE_ENV === 'development') {
199
+ const dbg = (r as any).__debug
200
+ if (dbg && update !== prev) {
201
+ console.groupCollapsed(`setStateShallow CHANGE`, '=>', update)
202
+ console.info(`previously`, prev)
203
+ console.trace()
204
+ console.groupEnd()
205
+ }
206
+ }
207
+ return update
208
+ })
209
+ }
210
+ }
211
+ if (process.env.NODE_ENV === 'development') {
212
+ ;(stateRef.current as any).__debug = props.debug
213
+ }
214
+ const setStateShallow = stateRef.current.baseSetStateShallow!
215
+ if (process.env.NODE_ENV === 'development' && globalThis.time)
216
+ globalThis.time`state-useCreateShallowSetState`
180
217
 
181
218
  // set enter/exit variants onto our new props object
182
219
  if (presenceState && isAnimated && isHydrated && staticConfig.variants) {
@@ -1,5 +1,5 @@
1
1
  import { isServer, isWeb } from '@tamagui/constants'
2
- import { useRef, useSyncExternalStore } from 'react'
2
+ import { useEffect, useReducer, useRef } from 'react'
3
3
  import { getSetting } from '../config'
4
4
  import { resetMediaStyleCache } from '../helpers/createMediaStyle'
5
5
  import { matchMedia } from '../helpers/matchMedia'
@@ -66,6 +66,8 @@ export const configureMedia = (config: TamaguiInternalConfig) => {
66
66
  mediaVersion++
67
67
  // reset cached media style prefixes/selectors so they get recalculated with new key order
68
68
  resetMediaStyleCache()
69
+ // touch-tracker getter object depends on the current media key set
70
+ resetMediaTouchTracker()
69
71
  for (const key in media) {
70
72
  getMedia()[key] = mediaQueryDefaultActive?.[key] || false
71
73
  mediaKeys.add(`$${key}`)
@@ -136,6 +138,47 @@ type MediaState = {
136
138
 
137
139
  const States = new WeakMap<any, MediaState>()
138
140
 
141
+ // shared "touch tracker" prototype: one object whose enumerable getter
142
+ // properties are pre-defined for every configured media key. Hermes inlines
143
+ // getter calls; the old `new Proxy(state, { get })` path forced an interpreted
144
+ // trap on every access — the dominant per-component cost in benchmarks. Each
145
+ // component owns just an Object.create(proto) with a Symbol-keyed slot
146
+ // pointing at its tracking set + current snapshot.
147
+ type MediaRefSlot = {
148
+ proxyTarget: MediaQueryState
149
+ keys: Set<string>
150
+ }
151
+ let touchTrackerProto: object | null = null
152
+ const refSlot = Symbol('mediaRefSlot')
153
+
154
+ function buildTouchTrackerProto(): object {
155
+ const proto: PropertyDescriptorMap = {}
156
+ for (const fullKey of mediaKeys) {
157
+ const key = fullKey[0] === '$' ? fullKey.slice(1) : fullKey
158
+ proto[key] = {
159
+ enumerable: true,
160
+ configurable: true,
161
+ get(this: { [refSlot]: MediaRefSlot }) {
162
+ const slot = this[refSlot]
163
+ if (!disableMediaTouch) {
164
+ slot.keys.add(key)
165
+ }
166
+ return slot.proxyTarget[key]
167
+ },
168
+ }
169
+ }
170
+ return Object.create(null, proto)
171
+ }
172
+
173
+ function getTouchTrackerProto(): object {
174
+ if (!touchTrackerProto) touchTrackerProto = buildTouchTrackerProto()
175
+ return touchTrackerProto
176
+ }
177
+
178
+ function resetMediaTouchTracker() {
179
+ touchTrackerProto = null
180
+ }
181
+
139
182
  export function setMediaShouldUpdate(
140
183
  ref: any,
141
184
  enabled?: boolean,
@@ -165,38 +208,46 @@ export function useMedia(
165
208
  ): UseMediaState {
166
209
  'use no memo'
167
210
 
168
- const componentState = componentContext ? States.get(componentContext) : null
169
-
170
- const internalRef = useRef<{
211
+ type MediaRef = {
171
212
  keys: Set<string>
172
213
  lastState: MediaQueryState
173
214
  pendingState?: MediaQueryState
174
- }>(null)
175
- if (!internalRef.current) {
176
- internalRef.current = {
177
- keys: new Set(),
178
- lastState: getMedia(),
179
- }
180
- }
181
-
182
- // reset on next render
183
- if (internalRef.current.pendingState) {
184
- internalRef.current.lastState = internalRef.current.pendingState
185
- internalRef.current.pendingState = undefined
186
- }
187
-
188
- const { keys } = internalRef.current
189
-
190
- // clear each render to track only rendered touched keys
191
- if (keys.size) {
192
- keys.clear()
215
+ renderVersion: number
216
+ unsubscribe?: () => void
217
+ // stable per-component closures + reusable Proxy. allocating new ones each
218
+ // render (via useSyncExternalStore + `new Proxy(state, ...)`) was a real
219
+ // per-component-per-render cost; we hold one Proxy whose target is swapped
220
+ // by mutating `proxyTarget` and re-reading it in the get trap.
221
+ proxyTarget: MediaQueryState
222
+ proxy: UseMediaState
223
+ getSnapshot: () => MediaQueryState
224
+ componentContext?: ComponentContextI
225
+ debug?: DebugProp
193
226
  }
194
227
 
195
- const state = useSyncExternalStore(
196
- subscribe,
197
- () => {
198
- const curKeys = componentState?.keys || keys
199
- const { lastState, pendingState } = internalRef.current!
228
+ const internalRef = useRef<MediaRef | null>(null)
229
+ if (!internalRef.current) {
230
+ const initial = getMedia()
231
+ const r: MediaRef = {
232
+ keys: new Set<string>(),
233
+ lastState: initial,
234
+ renderVersion: 0,
235
+ proxyTarget: initial,
236
+ proxy: undefined as unknown as UseMediaState,
237
+ getSnapshot: undefined as unknown as () => MediaQueryState,
238
+ componentContext,
239
+ debug,
240
+ }
241
+ // proxy → Object.create(getterProto) with a Symbol slot. Per-key get is a
242
+ // monomorphic getter call (Hermes-fast) instead of a Proxy trap.
243
+ const tracker = Object.create(getTouchTrackerProto())
244
+ tracker[refSlot] = { proxyTarget: initial, keys: r.keys } as MediaRefSlot
245
+ r.proxy = tracker as UseMediaState
246
+ r.getSnapshot = () => {
247
+ const curKeys = r.componentContext
248
+ ? States.get(r.componentContext)?.keys || r.keys
249
+ : r.keys
250
+ const { lastState, pendingState } = r
200
251
 
201
252
  if (!curKeys.size) {
202
253
  return lastState
@@ -205,38 +256,92 @@ export function useMedia(
205
256
  const ms = getMedia()
206
257
  for (const key of curKeys) {
207
258
  if (ms[key] !== (pendingState || lastState)[key]) {
208
- if (process.env.NODE_ENV === 'development' && debug) {
259
+ if (process.env.NODE_ENV === 'development' && r.debug) {
209
260
  console.warn(`useMedia() ✍️`, key, lastState[key], '=>', ms[key])
210
261
  }
211
262
 
212
263
  // in emitter mode (no-rerender) avoid changing state, instead emit
213
- if (componentContext?.mediaEmit) {
214
- componentContext.mediaEmit(ms)
215
- internalRef.current!.pendingState = ms
264
+ if (r.componentContext?.mediaEmit) {
265
+ r.componentContext.mediaEmit(ms)
266
+ r.pendingState = ms
216
267
  return lastState
217
268
  }
218
269
 
219
- internalRef.current!.lastState = ms
270
+ r.lastState = ms
220
271
 
221
272
  return ms
222
273
  }
223
274
  }
224
275
 
225
276
  return lastState
226
- },
227
- getServerSnapshot
228
- )
277
+ }
278
+ internalRef.current = r
279
+ } else {
280
+ // refresh per-render inputs the closures read through the ref
281
+ internalRef.current.componentContext = componentContext
282
+ internalRef.current.debug = debug
283
+ }
229
284
 
230
- return new Proxy(state, {
231
- get(_, key) {
232
- if (!disableMediaTouch && typeof key === 'string') {
233
- keys.add(key)
285
+ const ref = internalRef.current
286
+ ref.renderVersion++
287
+
288
+ // reset on next render
289
+ if (ref.pendingState) {
290
+ ref.lastState = ref.pendingState
291
+ ref.pendingState = undefined
292
+ }
293
+
294
+ // clear each render to track only rendered touched keys
295
+ if (ref.keys.size) {
296
+ ref.keys.clear()
297
+ }
298
+
299
+ // manual subscription (same shape as useThemeStateSubscribed): same
300
+ // granular bailout via getSnapshot returning the same MediaQueryState ref
301
+ // when none of the component's touched keys changed, but fewer
302
+ // React-internal hook slots on Hermes than useSyncExternalStore.
303
+ const [, forceUpdate] = useReducer(incReducer, 0)
304
+ const state = isServer ? initState : ref.getSnapshot()
305
+ ref.proxyTarget = state
306
+ ;(ref.proxy as any)[refSlot].proxyTarget = state
307
+
308
+ useEffect(() => {
309
+ const renderVersion = ref.renderVersion
310
+ const shouldSubscribe =
311
+ !ref.componentContext || !!States.get(ref.componentContext)?.enabled
312
+
313
+ if (shouldSubscribe) {
314
+ if (!ref.unsubscribe) {
315
+ ref.unsubscribe = subscribe(() => {
316
+ const next = ref.getSnapshot()
317
+ if (next !== ref.proxyTarget) {
318
+ ref.proxyTarget = next
319
+ ;(ref.proxy as any)[refSlot].proxyTarget = next
320
+ forceUpdate()
321
+ }
322
+ })
234
323
  }
235
- return Reflect.get(state, key)
236
- },
324
+ } else if (ref.unsubscribe) {
325
+ ref.unsubscribe()
326
+ ref.unsubscribe = undefined
327
+ }
328
+
329
+ return () => {
330
+ // react runs passive cleanup before the next effect as well as on unmount.
331
+ // a newer render bumps renderVersion before that cleanup, so equality here
332
+ // means this is the final unmount cleanup.
333
+ if (ref.renderVersion === renderVersion) {
334
+ ref.unsubscribe?.()
335
+ ref.unsubscribe = undefined
336
+ }
337
+ }
237
338
  })
339
+
340
+ return ref.proxy
238
341
  }
239
342
 
343
+ const incReducer = (c: number): number => c + 1
344
+
240
345
  const getServerSnapshot = () => initState
241
346
 
242
347
  let disableMediaTouch = false
@@ -1,10 +1,15 @@
1
- import { useRef } from 'react'
1
+ import { useRef, type MutableRefObject } from 'react'
2
2
  import type { ThemeParsed, ThemeState, UseThemeWithStateProps } from '../types'
3
3
  import { getThemeProxied, type ThemeProxied } from './getThemeProxied'
4
4
  import { useThemeState } from './useThemeState'
5
5
 
6
6
  const EMPTY = {}
7
7
 
8
+ type KeysBag = {
9
+ keys: MutableRefObject<Set<string> | null>
10
+ schemeKeys: MutableRefObject<Set<string> | null>
11
+ }
12
+
8
13
  export const useTheme = () => {
9
14
  'use no memo'
10
15
 
@@ -20,13 +25,30 @@ export type ThemeWithState = [ThemeParsed, ThemeState]
20
25
  */
21
26
  export const useThemeWithState = (
22
27
  props: UseThemeWithStateProps,
23
- isRoot = false
28
+ isRoot = false,
29
+ // <Theme> components push their themeState.id into ThemeStateContext,
30
+ // so their descendants subscribe under this id; <Theme> needs to schedule
31
+ // descendant updates when its propsKey changes. Leaf styled components
32
+ // (createComponent, useProps, useTheme, animation hooks) do NOT push a
33
+ // context — listenersByParent[componentId] is always empty — so the
34
+ // schedule-update effect is a no-op for them and can be skipped to save
35
+ // one hook slot per styled component on mount. Theme.tsx passes true.
36
+ forThemeView = false
24
37
  ): ThemeWithState => {
25
38
  'use no memo'
26
39
 
27
- const keys = useRef<Set<string> | null>(null)
28
- const schemeKeys = useRef<Set<string> | null>(null)
29
- const themeState = useThemeState(props, isRoot, keys, schemeKeys)
40
+ // single useRef holding both keys + schemeKeys; saves one hook slot per
41
+ // component vs separate useRef calls. these still look like MutableRefObject
42
+ // to getThemeProxied which only reads/writes `.current`.
43
+ const bag = useRef<KeysBag | null>(null)
44
+ if (!bag.current) {
45
+ bag.current = {
46
+ keys: { current: null },
47
+ schemeKeys: { current: null },
48
+ }
49
+ }
50
+ const { keys, schemeKeys } = bag.current
51
+ const themeState = useThemeState(props, isRoot, keys, schemeKeys, forThemeView)
30
52
 
31
53
  if (process.env.NODE_ENV === 'development') {
32
54
  if (!props.passThrough && !themeState?.theme) {