@tamagui/popper 2.0.0-rc.9 → 2.0.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.
package/src/Popper.tsx CHANGED
@@ -1,20 +1,24 @@
1
1
  // adapted from radix-ui popper
2
+ import { flushSync } from 'react-dom'
2
3
  import { useComposedRefs } from '@tamagui/compose-refs'
3
4
  import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
4
- import type { SizeTokens, ViewProps, TamaguiElement } from '@tamagui/core'
5
+ import type { SizeTokens, TamaguiElement, ViewProps } from '@tamagui/core'
5
6
  import {
6
7
  LayoutMeasurementController,
7
8
  View as TamaguiView,
8
9
  createStyledContext,
9
10
  getVariableValue,
11
+ registerLayoutNode,
10
12
  styled,
11
- useProps,
12
13
  } from '@tamagui/core'
14
+ import type { PopupTriggerMap } from '@tamagui/floating'
15
+ import { FloatingOverrideContext } from '@tamagui/floating'
13
16
  import type {
14
17
  Coords,
15
18
  Middleware,
16
19
  OffsetOptions,
17
20
  Placement,
21
+ ReferenceType,
18
22
  Side,
19
23
  SizeOptions,
20
24
  Strategy,
@@ -22,8 +26,8 @@ import type {
22
26
  } from '@tamagui/floating'
23
27
  import {
24
28
  arrow,
25
- autoUpdate,
26
29
  flip,
30
+ getOverflowAncestors,
27
31
  offset as offsetFn,
28
32
  platform,
29
33
  shift,
@@ -35,7 +39,7 @@ import type { SizableStackProps, YStackProps } from '@tamagui/stacks'
35
39
  import { YStack } from '@tamagui/stacks'
36
40
  import { startTransition } from '@tamagui/start-transition'
37
41
  import * as React from 'react'
38
- import { Keyboard, type View, useWindowDimensions } from 'react-native'
42
+ import { Keyboard, useWindowDimensions } from 'react-native'
39
43
 
40
44
  type ShiftProps = typeof shift extends (options: infer Opts) => void ? Opts : never
41
45
  type FlipProps = typeof flip extends (options: infer Opts) => void ? Opts : never
@@ -70,15 +74,18 @@ export const PopperPositionContext = createStyledContext
70
74
  export const { useStyledContext: usePopperContext, Provider: PopperProviderFast } =
71
75
  PopperContextFast
72
76
 
73
- export type PopperContextSlowValue = PopperContextShared &
74
- Pick<
75
- UseFloatingReturn,
76
- 'context' | 'getReferenceProps' | 'getFloatingProps' | 'strategy' | 'update' | 'refs'
77
- >
77
+ export type PopperContextSlowValue = Pick<
78
+ UseFloatingReturn,
79
+ 'getReferenceProps' | 'update' | 'refs'
80
+ > & {
81
+ onHoverReference?: (event: any) => void
82
+ onLeaveReference?: () => void
83
+ triggerElements?: PopupTriggerMap
84
+ }
78
85
 
79
86
  export const PopperContextSlow = createStyledContext<PopperContextSlowValue>(
80
87
  // since we always provide this we can avoid setting here
81
- {} as PopperContextValue,
88
+ {} as PopperContextSlowValue,
82
89
  'PopperSlow__'
83
90
  )
84
91
 
@@ -91,7 +98,32 @@ export const PopperProvider = ({
91
98
  children,
92
99
  ...context
93
100
  }: PopperContextValue & { scope?: string; children?: React.ReactNode }) => {
94
- const slowContext = getContextSlow(context)
101
+ // single ref holds all unstable functions — updated every render so the
102
+ // stable wrappers below always forward to the latest version
103
+ const fns = React.useRef(context)
104
+ fns.current = context
105
+
106
+ // stable wrappers that never change identity — objectIdentityKey in
107
+ // createStyledContext produces the same key across renders, so PopperAnchor
108
+ // instances never re-render from context changes (only from parent re-renders)
109
+ const [slowContext] = React.useState(
110
+ (): PopperContextSlowValue => ({
111
+ refs: context.refs,
112
+ triggerElements: context.triggerElements,
113
+ update(...a: []) {
114
+ fns.current.update(...a)
115
+ },
116
+ getReferenceProps(p?: any) {
117
+ return fns.current.getReferenceProps?.(p)
118
+ },
119
+ onHoverReference(e?: any) {
120
+ ;(fns.current as any).onHoverReference?.(e)
121
+ },
122
+ onLeaveReference() {
123
+ ;(fns.current as any).onLeaveReference?.()
124
+ },
125
+ })
126
+ )
95
127
 
96
128
  return (
97
129
  <PopperProviderFast scope={scope} {...context}>
@@ -102,24 +134,6 @@ export const PopperProvider = ({
102
134
  )
103
135
  }
104
136
 
105
- // avoid position based re-rendering
106
- function getContextSlow(context: PopperContextValue): PopperContextSlowValue {
107
- return {
108
- refs: context.refs,
109
- size: context.size,
110
- arrowRef: context.arrowRef,
111
- arrowStyle: context.arrowStyle,
112
- onArrowSize: context.onArrowSize,
113
- hasFloating: context.hasFloating,
114
- strategy: context.strategy,
115
- update: context.update,
116
- context: context.context,
117
- getFloatingProps: context.getFloatingProps,
118
- getReferenceProps: context.getReferenceProps,
119
- open: context.open,
120
- }
121
- }
122
-
123
137
  export type PopperProps = {
124
138
  /**
125
139
  * Popper is a component used by other components to create interfaces, so scope is required
@@ -141,7 +155,9 @@ export type PopperProps = {
141
155
  placement?: Placement
142
156
 
143
157
  /**
144
- * Attempts to shift the content to stay within the windiw
158
+ * Shifts content horizontally to stay within viewport.
159
+ * Pass an object to override shift options (mainAxis, crossAxis, padding, etc).
160
+ * Defaults: { mainAxis: true, crossAxis: false, padding: 10 }
145
161
  * @see https://floating-ui.com/docs/shift
146
162
  */
147
163
  stayInFrame?: ShiftProps | boolean
@@ -246,6 +262,58 @@ const transformOriginMiddleware = (options: {
246
262
  },
247
263
  })
248
264
 
265
+ // replaces floating-ui's autoUpdate with tamagui's batched IO measurement loop
266
+ // keeps scroll/resize listeners for immediate response, but replaces per-element
267
+ // ResizeObserver + IntersectionObserver with the shared layoutOnAnimationFrame loop
268
+ function tamaguiAutoUpdate(
269
+ reference: ReferenceType,
270
+ floating: HTMLElement,
271
+ update: () => void
272
+ ): () => void {
273
+ // initial position
274
+ update()
275
+
276
+ // schedule a second update after layout/scroll events settle (e.g. focus-
277
+ // triggered scrolls that cause flip corrections)
278
+ let rafId = requestAnimationFrame(() => {
279
+ update()
280
+ rafId = 0
281
+ })
282
+
283
+ const cleanups: (() => void)[] = [
284
+ () => {
285
+ if (rafId) cancelAnimationFrame(rafId)
286
+ },
287
+ ]
288
+
289
+ // watch reference element via tamagui's IO measurement loop
290
+ // only watch reference, NOT floating — watching floating causes loops
291
+ // (computePosition sets position → rect changes → update → repeat)
292
+ if (reference instanceof HTMLElement) {
293
+ cleanups.push(registerLayoutNode(reference, update))
294
+ }
295
+
296
+ // scroll listeners for immediate response (only for real DOM elements)
297
+ const refAncestors = reference instanceof Element ? getOverflowAncestors(reference) : []
298
+ const ancestors = [...refAncestors, ...getOverflowAncestors(floating)]
299
+ const uniqueAncestors = [...new Set(ancestors)]
300
+ for (const ancestor of uniqueAncestors) {
301
+ ancestor.addEventListener('scroll', update, { passive: true })
302
+ }
303
+
304
+ // window resize
305
+ window.addEventListener('resize', update)
306
+
307
+ cleanups.push(() => {
308
+ for (const ancestor of uniqueAncestors) {
309
+ ancestor.removeEventListener('scroll', update)
310
+ }
311
+ window.removeEventListener('resize', update)
312
+ })
313
+
314
+ return () => cleanups.forEach((fn) => fn())
315
+ }
316
+
249
317
  export function Popper(props: PopperProps) {
250
318
  const {
251
319
  children,
@@ -266,33 +334,35 @@ export function Popper(props: PopperProps) {
266
334
  const [arrowSize, setArrowSize] = React.useState(0)
267
335
  const offsetOptions = offset ?? arrowSize
268
336
  const floatingStyle = React.useRef({})
269
- const isOpen = passThrough ? false : open || true
270
-
271
- let floating = useFloating({
272
- open: isOpen,
273
- strategy,
274
- placement,
275
- sameScrollView: false, // this only takes effect on native
276
- whileElementsMounted: !isOpen ? undefined : autoUpdate,
277
- platform:
278
- (disableRTL ?? setupOptions.disableRTL)
279
- ? {
280
- ...platform,
281
- isRTL(element) {
282
- return false
283
- },
284
- }
285
- : platform,
286
- middleware: [
337
+ const isOpen = passThrough ? false : (open ?? true)
338
+
339
+ // freeze middleware reference when closed so floating-ui's deepEqual trivially
340
+ // passes (same object) and skips computePosition on re-renders while closed.
341
+ // unlike swapping to [], this retains the last good middleware so cached
342
+ // position data (offset, arrow, transformOrigin) stays correct for reopen.
343
+ const middlewareRef = React.useRef<any[]>([])
344
+ if (isOpen) {
345
+ middlewareRef.current = [
346
+ // order matters: offset first, then flip, then shift, then arrow
347
+ typeof offsetOptions !== 'undefined' ? offsetFn(offsetOptions) : (null as any),
348
+ allowFlip ? flip(typeof allowFlip === 'boolean' ? {} : allowFlip) : (null as any),
349
+ // NOTE: shift's axis terminology is reversed vs flip/offset:
350
+ // for top/bottom placements: mainAxis = horizontal, crossAxis = vertical
351
+ // for left/right placements: mainAxis = vertical, crossAxis = horizontal
352
+ // default to horizontal shift only (mainAxis: true, crossAxis: false)
287
353
  stayInFrame
288
- ? shift(typeof stayInFrame === 'boolean' ? {} : stayInFrame)
354
+ ? shift({
355
+ padding: 10,
356
+ mainAxis: true,
357
+ crossAxis: false,
358
+ ...(typeof stayInFrame === 'object' ? stayInFrame : null),
359
+ })
289
360
  : (null as any),
290
- allowFlip ? flip(typeof allowFlip === 'boolean' ? {} : allowFlip) : (null as any),
291
361
  arrowEl ? arrow({ element: arrowEl }) : (null as any),
292
- typeof offsetOptions !== 'undefined' ? offsetFn(offsetOptions) : (null as any),
293
362
  checkFloating,
294
363
  process.env.TAMAGUI_TARGET !== 'native' && resize
295
364
  ? sizeMiddleware({
365
+ padding: typeof stayInFrame === 'object' ? stayInFrame.padding : 10,
296
366
  apply({ availableHeight, availableWidth }) {
297
367
  if (passThrough) {
298
368
  return
@@ -343,7 +413,25 @@ export function Popper(props: PopperProps) {
343
413
  arrowWidth: arrowSize,
344
414
  })
345
415
  : (null as any),
346
- ].filter(Boolean),
416
+ ].filter(Boolean)
417
+ }
418
+
419
+ let floating = useFloating({
420
+ open: isOpen,
421
+ strategy,
422
+ placement,
423
+ sameScrollView: false, // this only takes effect on native
424
+ whileElementsMounted: !isOpen ? undefined : tamaguiAutoUpdate,
425
+ platform:
426
+ (disableRTL ?? setupOptions.disableRTL)
427
+ ? {
428
+ ...platform,
429
+ isRTL(element) {
430
+ return false
431
+ },
432
+ }
433
+ : platform,
434
+ middleware: middlewareRef.current,
347
435
  })
348
436
 
349
437
  if (process.env.TAMAGUI_TARGET !== 'native') {
@@ -399,8 +487,6 @@ export function Popper(props: PopperProps) {
399
487
  }, [passThrough, dimensions, keyboardOpen])
400
488
  }
401
489
 
402
- // memoize since we round x/y, floating-ui doesn't by default which can cause tons of updates
403
- // if the floating element is inside something animating with a spring
404
490
  const popperContext = React.useMemo(() => {
405
491
  return {
406
492
  size,
@@ -417,20 +503,20 @@ export function Popper(props: PopperProps) {
417
503
  }, [
418
504
  open,
419
505
  size,
420
- floating.x,
421
- floating.y,
422
- floating.placement,
506
+ floating,
423
507
  JSON.stringify(middlewareData.arrow || null),
424
508
  JSON.stringify(middlewareData.transformOrigin || null),
425
- floating.isPositioned,
426
509
  ])
427
510
 
428
511
  return (
429
- <LayoutMeasurementController disable={!isOpen}>
430
- <PopperProvider scope={scope} {...popperContext}>
512
+ <PopperProvider scope={scope} {...popperContext}>
513
+ {/* reset FloatingOverrideContext so it doesn't leak into nested Poppers —
514
+ each Popper consumes the override for its own useFloating, children
515
+ should not inherit it (e.g. a Menu inside a Tooltip's tree) */}
516
+ <FloatingOverrideContext.Provider value={null}>
431
517
  {children}
432
- </PopperProvider>
433
- </LayoutMeasurementController>
518
+ </FloatingOverrideContext.Provider>
519
+ </PopperProvider>
434
520
  )
435
521
  }
436
522
 
@@ -452,10 +538,25 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
452
538
  const context = usePopperContextSlow(scope)
453
539
  const { getReferenceProps, refs, update } = context
454
540
  const ref = React.useRef<PopperAnchorRef>(null)
541
+ const triggerId = React.useId()
542
+
543
+ // register this trigger element with the shared trigger map
544
+ // so useHover can detect cursor moves between sibling triggers
545
+ React.useEffect(() => {
546
+ if (!scope || !context.triggerElements || !ref.current) return
547
+ if (!(ref.current instanceof Element)) return
548
+ const el = ref.current as Element
549
+ context.triggerElements.add(triggerId, el)
550
+ return () => {
551
+ context.triggerElements?.delete(triggerId)
552
+ }
553
+ }, [scope, triggerId, context.triggerElements])
455
554
 
456
555
  React.useEffect(() => {
457
556
  if (virtualRef) {
458
557
  refs.setReference(virtualRef.current)
558
+ // recompute position after setting virtual reference
559
+ update()
459
560
  }
460
561
  }, [virtualRef])
461
562
 
@@ -472,7 +573,8 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
472
573
  refs.setReference(node)
473
574
  })
474
575
  },
475
- [refs.setReference]
576
+ // it was refs.setRefernce but its stable and refs is undefined on server
577
+ [refs]
476
578
  )
477
579
 
478
580
  const shouldHandleInHover = isWeb && scope
@@ -489,22 +591,30 @@ export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
489
591
  {...refProps}
490
592
  ref={composedRefs}
491
593
  {...(shouldHandleInHover && {
492
- // this helps us with handling scoped poppers with many different targets
493
- // basically we wait for mouseEnter to ever set a reference and remove it on leave
494
- // otherwise floating ui gets confused by having >1 reference
594
+ // scoped poppers with multiple triggers: set the reference on
595
+ // mouseEnter so floating-ui positions relative to the hovered
596
+ // trigger, not the last one rendered.
597
+ //
598
+ // flushSync is critical here: without it, setReference batches
599
+ // with React's async state updates and the arrow/content position
600
+ // computes against the OLD reference element. this causes the
601
+ // arrow to flash at x=0 (top-left) during trigger switches.
602
+ // flushSync forces synchronous commit so update() below reads
603
+ // the correct reference element immediately.
495
604
  onMouseEnter: (e) => {
496
- if (ref.current instanceof HTMLElement) {
497
- refs.setReference(ref.current)
605
+ const el = (e.currentTarget ?? ref.current) as HTMLElement | null
606
+ if (el instanceof HTMLElement) {
607
+ flushSync(() => refs.setReference(el))
608
+ update()
498
609
 
499
- if (!refProps) {
500
- return
501
- }
610
+ if (!refProps) return
502
611
 
503
612
  refProps.onPointerEnter?.(e)
504
- update()
613
+ context.onHoverReference?.(e.nativeEvent)
505
614
  }
506
615
  },
507
616
  onMouseLeave: (e) => {
617
+ context.onLeaveReference?.()
508
618
  refProps?.onMouseLeave?.(e)
509
619
  },
510
620
  })}
@@ -535,11 +645,7 @@ export const PopperContentFrame = styled(YStack, {
535
645
 
536
646
  variants: {
537
647
  unstyled: {
538
- false: {
539
- size: '$true',
540
- backgroundColor: '$background',
541
- alignItems: 'center',
542
- },
648
+ true: {},
543
649
  },
544
650
 
545
651
  size: {
@@ -551,20 +657,25 @@ export const PopperContentFrame = styled(YStack, {
551
657
  },
552
658
  },
553
659
  } as const,
554
-
555
- defaultVariants: {
556
- unstyled: process.env.TAMAGUI_HEADLESS === '1',
557
- },
558
660
  })
559
661
 
560
662
  export const PopperContent = React.forwardRef<PopperContentElement, PopperContentProps>(
561
663
  function PopperContent(props, forwardedRef) {
664
+ // detect controlled animatePosition before destructuring. when the user passes
665
+ // animatePosition (even with a currently-falsy value like undefined or false),
666
+ // toggling it later must not flip 'transition' presence on the inner View - that
667
+ // would change useComponentState's hasAnimationProp mid-life, conditionally calling
668
+ // useAnimations/usePresence and tripping React's "Should have a queue" invariant.
669
+ const isAnimatePosControlled =
670
+ 'animatePosition' in props || 'enableAnimationForPositionChange' in props
671
+
562
672
  const {
563
673
  scope,
564
674
  animatePosition,
565
675
  enableAnimationForPositionChange,
566
676
  children,
567
677
  passThrough,
678
+ unstyled,
568
679
  ...rest
569
680
  } = props
570
681
  const animatePos = animatePosition ?? enableAnimationForPositionChange
@@ -580,19 +691,60 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
580
691
  size,
581
692
  isPositioned,
582
693
  transformOrigin,
694
+ update,
583
695
  } = context
584
696
 
585
- // Wrap setFloating in startTransition to avoid React #185 (setState during render)
586
- // This can happen during rapid navigation when refs are set during render phase
697
+ // keep update() accessible inside safeSetFloating without adding it as a dep
698
+ const updateRef = React.useRef(update)
699
+ updateRef.current = update
700
+
701
+ // ref callback: call refs.setFloating directly (no startTransition) so floating-ui's
702
+ // state update runs synchronously and position is computed on mount.
703
+ // note: ref callbacks fire during the commit phase, not render, so calling setState
704
+ // here is safe - React batches it for the next commit.
705
+ //
706
+ // when animatePosition=true, disableAnimation state changes cycle the DOM node
707
+ // (null then re-mount). we block all null calls here to prevent floating-ui from
708
+ // losing its reference mid-cycle; genuine unmount is handled by the useEffect below.
709
+ // for same-node cycling (animateOnly prop change without remount), refs.setFloating
710
+ // is a no-op in floating-ui (same-node guard), so we call update() to force recompute.
711
+ const lastNodeRef = React.useRef<any>(null)
587
712
  const safeSetFloating = React.useCallback(
588
713
  (node: any) => {
589
- startTransition(() => {
714
+ const isNewNode = node !== lastNodeRef.current
715
+ if (node) {
716
+ lastNodeRef.current = node
590
717
  refs.setFloating(node)
591
- })
718
+ if (!isNewNode) {
719
+ // same node re-appeared (prop cycling without remount):
720
+ // refs.setFloating is a no-op, so force position recompute
721
+ updateRef.current?.()
722
+ }
723
+ }
724
+ // null calls are blocked: cycling nulls are transient, genuine unmount
725
+ // is handled by the useEffect cleanup below
592
726
  },
593
727
  [refs.setFloating]
594
728
  )
595
729
 
730
+ // clear floating-ui's reference when the component genuinely unmounts.
731
+ // IMPORTANT: useEffect cleanup is deferred — when PopperContent remounts
732
+ // (e.g. animation prop cycling), the new instance's ref callback fires
733
+ // BEFORE this cleanup runs. without the guard, we'd null out the ref that
734
+ // the new instance just set, causing all subsequent update() calls to
735
+ // early-return (the "stuck tooltip" bug).
736
+ React.useEffect(() => {
737
+ return () => {
738
+ const ourNode = lastNodeRef.current
739
+ // only clear if floating-ui still points to OUR node — if a new
740
+ // instance already set a different node, don't touch it
741
+ if (ourNode && refs.floating.current === ourNode) {
742
+ refs.setFloating(null)
743
+ }
744
+ lastNodeRef.current = null
745
+ }
746
+ }, [])
747
+
596
748
  const contentRefs = useComposedRefs<any>(safeSetFloating, forwardedRef)
597
749
 
598
750
  const [needsMeasure, setNeedsMeasure] = React.useState(animatePos)
@@ -603,28 +755,52 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
603
755
  }
604
756
  }, [needsMeasure, animatePos, x, y])
605
757
 
606
- // default to not showing if positioned at 0, 0
607
- const hide = x === 0 && y === 0
758
+ // track whether we've ever been positioned. floating-ui resets isPositioned
759
+ // to false when open changes to false (e.g. hoverable safePolygon briefly
760
+ // closing). without this, the brief close disables animation and causes
761
+ // position jumps when the popover reopens at the new trigger.
762
+ const hasBeenPositioned = React.useRef(false)
763
+ const lastGoodPosition = React.useRef({ x: 0, y: 0 })
764
+ if (x !== 0 || y !== 0) {
765
+ // always track the latest computed position so that when a new reference
766
+ // is set while closed (e.g. content → gap → different trigger), the
767
+ // effectiveX/Y fallback uses the fresh position, not the stale one
768
+ lastGoodPosition.current = { x, y }
769
+ if (isPositioned) {
770
+ hasBeenPositioned.current = true
771
+ }
772
+ }
773
+
774
+ // use the last known good position when floating-ui provides 0,0.
775
+ // this happens in two cases:
776
+ // 1. close/reopen cycle: isPositioned resets to false
777
+ // 2. trigger switch: reference element changes, floating-ui briefly
778
+ // provides x=0,y=0 while isPositioned is still true, causing the
779
+ // animation driver to animate toward (0,0) for 2-3 frames
780
+ const brieflyZero = hasBeenPositioned.current && x === 0 && y === 0
781
+ const effectiveX = brieflyZero ? lastGoodPosition.current.x : x
782
+ const effectiveY = brieflyZero ? lastGoodPosition.current.y : y
783
+
784
+ // only hide before the very first positioning
785
+ const hide = !hasBeenPositioned.current && effectiveX === 0 && effectiveY === 0
608
786
 
609
787
  const disableAnimationProp =
610
788
  // if they want to animate also when re-positioning allow it
611
789
  animatePos === 'even-when-repositioning'
612
790
  ? needsMeasure
613
- : !isPositioned || needsMeasure
791
+ : (!hasBeenPositioned.current && !isPositioned) || needsMeasure
614
792
 
615
793
  const [disableAnimation, setDisableAnimation] = React.useState(disableAnimationProp)
616
794
 
617
- // we set this delayed because we need to pass to the animation driver the value and then update it
795
+ // set in an effect so we apply the css transition only after the element is positioned,
796
+ // not on the first render (which would animate from y=0 to the actual position)
618
797
  React.useEffect(() => {
619
798
  setDisableAnimation(disableAnimationProp)
620
799
  }, [disableAnimationProp])
621
800
 
622
- // when position not calculated yet (hide=true means x===0 && y===0),
623
- // don't pass x/y to avoid motion driver capturing 0,0 as starting position
624
- // and then animating from 0,0 to the real position (causes visual jump)
625
801
  const positionProps = hide
626
- ? {} // omit x/y when hiding - prevents motion from animating from origin
627
- : { x: x || 0, y: y || 0 }
802
+ ? {} // omit x/y when hiding - prevents motion driver from animating from origin
803
+ : { x: effectiveX || 0, y: effectiveY || 0 }
628
804
 
629
805
  const frameProps = {
630
806
  ref: contentRefs,
@@ -632,17 +808,18 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
632
808
  top: 0,
633
809
  left: 0,
634
810
  position: strategy,
635
- opacity: 1,
636
- ...(animatePos && {
637
- transition: rest.transition,
638
- animateOnly: disableAnimation ? [] : rest.animateOnly,
639
- // apply animation but disable it on initial render to avoid animating from 0 to the first position
811
+ opacity: hide ? 0 : 1,
812
+ // when animatePosition is controlled by the user, always emit these keys with
813
+ // safe no-op values (transition: undefined, animateOnly: []) so the inner
814
+ // View's hook count stays stable across animatePos toggles. animatePresence
815
+ // must always be false here too, to short-circuit usePresence consistently.
816
+ ...(isAnimatePosControlled && {
817
+ transition: animatePos ? rest.transition : undefined,
818
+ // animateOnly: [] turns off transitions while keeping styles applied,
819
+ // letting the element move to its position silently before animations start
820
+ animateOnly: animatePos && !disableAnimation ? rest.animateOnly : [],
640
821
  animatePresence: false,
641
822
  }),
642
- ...(hide && {
643
- opacity: 0,
644
- animateOnly: [],
645
- }),
646
823
  }
647
824
 
648
825
  // outer frame because we explicitly don't want animation to apply to this
@@ -658,33 +835,34 @@ export const PopperContent = React.forwardRef<PopperContentElement, PopperConten
658
835
  : undefined
659
836
 
660
837
  return (
661
- <TamaguiView
662
- passThrough={passThrough}
663
- ref={contentRefs}
664
- contain="layout style"
665
- {...(passThrough ? null : floatingProps)}
666
- {...(!passThrough &&
667
- animatePos && {
668
- // marker for animation driver to know this is a popper element
669
- // that needs special handling for position animation interruption
670
- 'data-popper-animate-position': 'true',
671
- })}
672
- >
673
- <PopperContentFrame
674
- key="popper-content-frame"
838
+ <LayoutMeasurementController disable={!context.open}>
839
+ <TamaguiView
675
840
  passThrough={passThrough}
676
- {...(!passThrough && {
677
- 'data-placement': placement,
678
- 'data-strategy': strategy,
679
- size,
680
- ...style,
681
- ...transformOriginStyle,
682
- ...rest,
683
- })}
841
+ ref={contentRefs}
842
+ direction={rest.direction}
843
+ {...(passThrough ? null : floatingProps)}
844
+ {...(!passThrough &&
845
+ animatePos && {
846
+ 'data-popper-animate-position': 'true',
847
+ })}
684
848
  >
685
- {children}
686
- </PopperContentFrame>
687
- </TamaguiView>
849
+ <PopperContentFrame
850
+ key="popper-content-frame"
851
+ passThrough={passThrough}
852
+ unstyled={unstyled}
853
+ {...(!passThrough && {
854
+ 'data-placement': placement,
855
+ 'data-strategy': strategy,
856
+ size,
857
+ ...style,
858
+ ...transformOriginStyle,
859
+ ...rest,
860
+ })}
861
+ >
862
+ {children}
863
+ </PopperContentFrame>
864
+ </TamaguiView>
865
+ </LayoutMeasurementController>
688
866
  )
689
867
  }
690
868
  )
@@ -755,11 +933,14 @@ type Sides = keyof typeof opposites
755
933
 
756
934
  export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
757
935
  function PopperArrow(propsIn, forwardedRef) {
936
+ // see PopperContent for why we detect controlled animatePosition before destructuring
937
+ const isAnimatePosControlled = 'animatePosition' in propsIn
758
938
  const { scope, animatePosition, transition, ...rest } = propsIn
759
- const props = useProps(rest)
760
- const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = props
939
+ const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = rest
761
940
 
762
941
  const context = usePopperContext(scope)
942
+
943
+ // TODO: get rid! at the very least move up to Popover and simplify
763
944
  const sizeVal =
764
945
  typeof sizeProp === 'number'
765
946
  ? sizeProp
@@ -780,6 +961,10 @@ export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
780
961
  const x = (context.arrowStyle?.x as number) || 0
781
962
  const y = (context.arrowStyle?.y as number) || 0
782
963
 
964
+ // hide arrow until floating-ui has computed its position to prevent
965
+ // flash at x=0 during initial render or trigger switches in hydration
966
+ const arrowPositioned = context.arrowStyle != null
967
+
783
968
  const primaryPlacement = (placement ? placement.split('-')[0] : 'top') as Sides
784
969
 
785
970
  const arrowStyle: ViewProps = { x, y, width: size, height: size }
@@ -813,9 +998,10 @@ export const PopperArrow = React.forwardRef<TamaguiElement, PopperArrowProps>(
813
998
  <PopperArrowOuterFrame
814
999
  ref={refs}
815
1000
  {...arrowStyle}
816
- {...(animatePosition && {
817
- transition,
818
- animateOnly: ['transform'],
1001
+ {...(!arrowPositioned && { opacity: 0 })}
1002
+ {...(isAnimatePosControlled && {
1003
+ transition: animatePosition ? transition : undefined,
1004
+ animateOnly: animatePosition ? ['transform'] : [],
819
1005
  animatePresence: false,
820
1006
  })}
821
1007
  >