@tamagui/web 2.3.3 → 2.4.0

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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cjs/createComponent.native.js +1 -1
  3. package/dist/cjs/createComponent.native.js.map +1 -1
  4. package/dist/cjs/helpers/getSplitStyles.cjs +5 -0
  5. package/dist/cjs/helpers/getSplitStyles.native.js +9 -0
  6. package/dist/cjs/helpers/getSplitStyles.native.js.map +1 -1
  7. package/dist/cjs/helpers/pointerEvents.native.js +26 -20
  8. package/dist/cjs/helpers/pointerEvents.native.js.map +1 -1
  9. package/dist/cjs/hooks/useComponentState.cjs +25 -1
  10. package/dist/cjs/hooks/useComponentState.native.js +25 -1
  11. package/dist/cjs/hooks/useComponentState.native.js.map +1 -1
  12. package/dist/cjs/hooks/useMedia.cjs +107 -42
  13. package/dist/cjs/hooks/useMedia.native.js +149 -60
  14. package/dist/cjs/hooks/useMedia.native.js.map +1 -1
  15. package/dist/cjs/hooks/useTheme.cjs +17 -4
  16. package/dist/cjs/hooks/useTheme.native.js +18 -4
  17. package/dist/cjs/hooks/useTheme.native.js.map +1 -1
  18. package/dist/cjs/hooks/useThemeState.cjs +132 -67
  19. package/dist/cjs/hooks/useThemeState.native.js +144 -70
  20. package/dist/cjs/hooks/useThemeState.native.js.map +1 -1
  21. package/dist/cjs/index.cjs +2 -0
  22. package/dist/cjs/index.native.js +2 -0
  23. package/dist/cjs/index.native.js.map +1 -1
  24. package/dist/cjs/views/Theme.cjs +1 -1
  25. package/dist/cjs/views/Theme.native.js +1 -1
  26. package/dist/cjs/views/Theme.native.js.map +1 -1
  27. package/dist/esm/createComponent.native.js +2 -2
  28. package/dist/esm/createComponent.native.js.map +1 -1
  29. package/dist/esm/helpers/getSplitStyles.mjs +5 -0
  30. package/dist/esm/helpers/getSplitStyles.mjs.map +1 -1
  31. package/dist/esm/helpers/getSplitStyles.native.js +9 -0
  32. package/dist/esm/helpers/getSplitStyles.native.js.map +1 -1
  33. package/dist/esm/helpers/pointerEvents.native.js +26 -20
  34. package/dist/esm/helpers/pointerEvents.native.js.map +1 -1
  35. package/dist/esm/hooks/useComponentState.mjs +26 -2
  36. package/dist/esm/hooks/useComponentState.mjs.map +1 -1
  37. package/dist/esm/hooks/useComponentState.native.js +26 -2
  38. package/dist/esm/hooks/useComponentState.native.js.map +1 -1
  39. package/dist/esm/hooks/useMedia.mjs +108 -43
  40. package/dist/esm/hooks/useMedia.mjs.map +1 -1
  41. package/dist/esm/hooks/useMedia.native.js +150 -61
  42. package/dist/esm/hooks/useMedia.native.js.map +1 -1
  43. package/dist/esm/hooks/useTheme.mjs +17 -4
  44. package/dist/esm/hooks/useTheme.mjs.map +1 -1
  45. package/dist/esm/hooks/useTheme.native.js +18 -4
  46. package/dist/esm/hooks/useTheme.native.js.map +1 -1
  47. package/dist/esm/hooks/useThemeState.mjs +133 -68
  48. package/dist/esm/hooks/useThemeState.mjs.map +1 -1
  49. package/dist/esm/hooks/useThemeState.native.js +145 -71
  50. package/dist/esm/hooks/useThemeState.native.js.map +1 -1
  51. package/dist/esm/index.js +3 -3
  52. package/dist/esm/index.js.map +1 -1
  53. package/dist/esm/index.mjs +3 -3
  54. package/dist/esm/index.mjs.map +1 -1
  55. package/dist/esm/index.native.js +3 -3
  56. package/dist/esm/index.native.js.map +1 -1
  57. package/dist/esm/views/Theme.mjs +1 -1
  58. package/dist/esm/views/Theme.mjs.map +1 -1
  59. package/dist/esm/views/Theme.native.js +1 -1
  60. package/dist/esm/views/Theme.native.js.map +1 -1
  61. package/package.json +13 -13
  62. package/src/createComponent.tsx +8 -2
  63. package/src/helpers/getSplitStyles.tsx +33 -0
  64. package/src/helpers/pointerEvents.native.ts +26 -19
  65. package/src/hooks/useComponentState.ts +39 -2
  66. package/src/hooks/useMedia.tsx +147 -42
  67. package/src/hooks/useTheme.tsx +27 -5
  68. package/src/hooks/useThemeState.ts +263 -137
  69. package/src/index.ts +7 -1
  70. package/src/types.tsx +53 -5
  71. package/src/views/Theme.tsx +4 -1
  72. package/types/createComponent.d.ts.map +1 -1
  73. package/types/helpers/getSplitStyles.d.ts.map +1 -1
  74. package/types/helpers/pointerEvents.native.d.ts.map +1 -1
  75. package/types/hooks/useComponentState.d.ts +1 -1
  76. package/types/hooks/useComponentState.d.ts.map +1 -1
  77. package/types/hooks/useMedia.d.ts.map +1 -1
  78. package/types/hooks/useTheme.d.ts +1 -1
  79. package/types/hooks/useTheme.d.ts.map +1 -1
  80. package/types/hooks/useThemeState.d.ts +1 -1
  81. package/types/hooks/useThemeState.d.ts.map +1 -1
  82. package/types/index.d.ts +2 -2
  83. package/types/index.d.ts.map +1 -1
  84. package/types/types.d.ts +3 -1
  85. package/types/types.d.ts.map +1 -1
  86. package/types/views/Theme.d.ts.map +1 -1
@@ -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) {