@oxyhq/bloom 1.13.0 → 1.14.1

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 (114) hide show
  1. package/README.md +1 -1
  2. package/docs/avatar.mdx +46 -5
  3. package/docs/index.mdx +1 -1
  4. package/docs/media-flight.mdx +15 -6
  5. package/docs/tab-bar.mdx +35 -0
  6. package/lib/commonjs/avatar/Avatar.js +24 -8
  7. package/lib/commonjs/avatar/Avatar.js.map +1 -1
  8. package/lib/commonjs/avatar/AvatarRing.js +13 -12
  9. package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
  10. package/lib/commonjs/avatar/index.js +19 -0
  11. package/lib/commonjs/avatar/index.js.map +1 -1
  12. package/lib/commonjs/avatar/resolve-shape.js +43 -0
  13. package/lib/commonjs/avatar/resolve-shape.js.map +1 -0
  14. package/lib/commonjs/avatar/shape-paths.js +174 -0
  15. package/lib/commonjs/avatar/shape-paths.js.map +1 -0
  16. package/lib/commonjs/media-flight/MediaSurface.js +51 -10
  17. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  18. package/lib/commonjs/media-flight/constants.js +32 -7
  19. package/lib/commonjs/media-flight/constants.js.map +1 -1
  20. package/lib/commonjs/tab-bar/TabBarBase.js +87 -13
  21. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  22. package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js +5 -0
  23. package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js.map +1 -1
  24. package/lib/commonjs/theme/color-scope/ColorScope.js +42 -12
  25. package/lib/commonjs/theme/color-scope/ColorScope.js.map +1 -1
  26. package/lib/commonjs/theme/color-scope/ColorScope.web.js +2 -8
  27. package/lib/commonjs/theme/color-scope/ColorScope.web.js.map +1 -1
  28. package/lib/module/avatar/Avatar.js +24 -8
  29. package/lib/module/avatar/Avatar.js.map +1 -1
  30. package/lib/module/avatar/AvatarRing.js +13 -12
  31. package/lib/module/avatar/AvatarRing.js.map +1 -1
  32. package/lib/module/avatar/index.js +1 -0
  33. package/lib/module/avatar/index.js.map +1 -1
  34. package/lib/module/avatar/resolve-shape.js +40 -0
  35. package/lib/module/avatar/resolve-shape.js.map +1 -0
  36. package/lib/module/avatar/shape-paths.js +170 -0
  37. package/lib/module/avatar/shape-paths.js.map +1 -0
  38. package/lib/module/media-flight/MediaSurface.js +52 -11
  39. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  40. package/lib/module/media-flight/constants.js +31 -6
  41. package/lib/module/media-flight/constants.js.map +1 -1
  42. package/lib/module/tab-bar/TabBarBase.js +89 -15
  43. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  44. package/lib/module/tab-bar/expo-router/RouterTabBar.js +5 -0
  45. package/lib/module/tab-bar/expo-router/RouterTabBar.js.map +1 -1
  46. package/lib/module/theme/color-scope/ColorScope.js +45 -13
  47. package/lib/module/theme/color-scope/ColorScope.js.map +1 -1
  48. package/lib/module/theme/color-scope/ColorScope.web.js +3 -9
  49. package/lib/module/theme/color-scope/ColorScope.web.js.map +1 -1
  50. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts +4 -3
  52. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/avatar/index.d.ts +3 -1
  54. package/lib/typescript/commonjs/avatar/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/avatar/resolve-shape.d.ts +17 -0
  56. package/lib/typescript/commonjs/avatar/resolve-shape.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/avatar/shape-paths.d.ts +27 -0
  58. package/lib/typescript/commonjs/avatar/shape-paths.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/avatar/types.d.ts +30 -3
  60. package/lib/typescript/commonjs/avatar/types.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/media-flight/constants.d.ts +30 -6
  63. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/media-flight/types.d.ts +7 -2
  65. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
  68. package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/tab-bar/types.d.ts +36 -5
  70. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/theme/color-scope/ColorScope.d.ts +36 -2
  72. package/lib/typescript/commonjs/theme/color-scope/ColorScope.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts +6 -2
  74. package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  75. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  76. package/lib/typescript/module/avatar/AvatarRing.d.ts +4 -3
  77. package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
  78. package/lib/typescript/module/avatar/index.d.ts +3 -1
  79. package/lib/typescript/module/avatar/index.d.ts.map +1 -1
  80. package/lib/typescript/module/avatar/resolve-shape.d.ts +17 -0
  81. package/lib/typescript/module/avatar/resolve-shape.d.ts.map +1 -0
  82. package/lib/typescript/module/avatar/shape-paths.d.ts +27 -0
  83. package/lib/typescript/module/avatar/shape-paths.d.ts.map +1 -0
  84. package/lib/typescript/module/avatar/types.d.ts +30 -3
  85. package/lib/typescript/module/avatar/types.d.ts.map +1 -1
  86. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  87. package/lib/typescript/module/media-flight/constants.d.ts +30 -6
  88. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -1
  89. package/lib/typescript/module/media-flight/types.d.ts +7 -2
  90. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  91. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  92. package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
  93. package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
  94. package/lib/typescript/module/tab-bar/types.d.ts +36 -5
  95. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  96. package/lib/typescript/module/theme/color-scope/ColorScope.d.ts +36 -2
  97. package/lib/typescript/module/theme/color-scope/ColorScope.d.ts.map +1 -1
  98. package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts +6 -2
  99. package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  100. package/package.json +1 -1
  101. package/src/avatar/Avatar.tsx +27 -10
  102. package/src/avatar/AvatarRing.tsx +21 -14
  103. package/src/avatar/index.ts +7 -0
  104. package/src/avatar/resolve-shape.ts +33 -0
  105. package/src/avatar/shape-paths.ts +150 -0
  106. package/src/avatar/types.ts +32 -3
  107. package/src/media-flight/MediaSurface.tsx +60 -14
  108. package/src/media-flight/constants.ts +31 -6
  109. package/src/media-flight/types.ts +7 -2
  110. package/src/tab-bar/TabBarBase.tsx +110 -17
  111. package/src/tab-bar/expo-router/RouterTabBar.tsx +5 -0
  112. package/src/tab-bar/types.ts +36 -5
  113. package/src/theme/color-scope/ColorScope.tsx +53 -12
  114. package/src/theme/color-scope/ColorScope.web.tsx +10 -8
@@ -22,13 +22,21 @@ import {
22
22
  useMemo,
23
23
  type ComponentType,
24
24
  } from 'react';
25
- import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
25
+ import {
26
+ Platform,
27
+ Pressable,
28
+ StyleSheet,
29
+ useWindowDimensions,
30
+ View,
31
+ type ViewStyle,
32
+ } from 'react-native';
26
33
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
27
34
  import Animated, {
28
35
  Extrapolation,
29
36
  interpolate,
30
37
  interpolateColor,
31
38
  runOnJS,
39
+ useAnimatedReaction,
32
40
  useAnimatedStyle,
33
41
  useSharedValue,
34
42
  withSpring,
@@ -83,6 +91,13 @@ type BarContextValue = {
83
91
  theme: TabBarTheme;
84
92
  /** Set only on the CONTROLLED path (see `TabBarProps.activeIndex`). */
85
93
  activeIndex: number | undefined;
94
+ /**
95
+ * True while a driver owns the highlight's POSITION (see
96
+ * `TabBarProps.activeProgress`). Buttons read it to stand down: their focus
97
+ * effect and their press must not spring `slideIndex` out from under a pager
98
+ * mid-gesture. It says nothing about visibility, which stays where it was.
99
+ */
100
+ driven: boolean;
86
101
  /** Report a selection made by a button press. */
87
102
  selectIndex: (index: number) => void;
88
103
  };
@@ -103,6 +118,7 @@ function TabBarBody({
103
118
  Blur,
104
119
  children,
105
120
  activeIndex,
121
+ activeProgress,
106
122
  onIndexChange,
107
123
  onIndexLongPress,
108
124
  theme: themeOverrides,
@@ -189,6 +205,33 @@ function TabBarBody({
189
205
  // a legitimate effect (external-system sync), the same shape `Tabs` uses for
190
206
  // its underline. The focus-driven path is handled per-button instead — see
191
207
  // `TabBarButtonBody`.
208
+ // DRIVEN path: a pager owns the highlight's position and writes it every
209
+ // frame, so the position is COPIED rather than sprung — a spring chasing a
210
+ // per-frame value lags behind the finger, which is the whole thing this path
211
+ // exists to avoid.
212
+ //
213
+ // `useAnimatedReaction` cannot be called conditionally, so an unsupplied
214
+ // `activeProgress` is stood in for by a shared value nothing ever writes and
215
+ // the reaction returns early. `driven` changing flips the identity of `source`
216
+ // too, and both are in the deps array, so the reaction re-registers either
217
+ // way.
218
+ //
219
+ // Scrubbing still wins: a finger on the BAR is a direct manipulation of the
220
+ // bar and must not be fought by the pager's settle animation.
221
+ // (Deps: see the CRITICAL note below.)
222
+ const idleProgress = useSharedValue(0);
223
+ const driven = activeProgress !== undefined;
224
+ const source = activeProgress ?? idleProgress;
225
+ useAnimatedReaction(
226
+ () => source.value,
227
+ (value) => {
228
+ if (!driven) return;
229
+ if (isDragging.value) return;
230
+ slideIndex.value = value;
231
+ },
232
+ [source, driven, isDragging, slideIndex],
233
+ );
234
+
192
235
  useEffect(() => {
193
236
  if (activeIndex === undefined) return;
194
237
  // While scrubbing the finger owns the highlight; never fight it.
@@ -209,16 +252,29 @@ function TabBarBody({
209
252
  // stale index would animate out of a position the user never saw, and would
210
253
  // light up every tab in between on the way. Interrupted mid-fade it is
211
254
  // still on screen, so from there it slides as it always does.
212
- slideIndex.value =
213
- highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
255
+ //
256
+ // On the DRIVEN path this effect keeps owning visibility and gives up the
257
+ // position: the driver is already writing `slideIndex` every frame, and a
258
+ // spring started here would drag the capsule across the bar between two of
259
+ // its writes.
260
+ if (!driven) {
261
+ slideIndex.value =
262
+ highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
263
+ }
214
264
  highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
215
- }, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
265
+ }, [activeIndex, hasSelection, driven, slideIndex, highlightOpacity, isDragging]);
216
266
 
217
267
  // Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
218
268
  // it must feel attached), haptic ticks fire on boundary crossings, and
219
269
  // navigation happens only on release. Taps are handled by a Tap gesture
220
- // racing the pan — the detector consumes the bar's touches, so the inner
221
- // Pressables never receive them.
270
+ // racing the pan.
271
+ //
272
+ // THE DETECTOR DOES NOT CONSUME THE PRESS, which this file assumed for a long
273
+ // time. A `Tap` gesture activates on RELEASE, by which point the inner
274
+ // `Pressable` has already fired `onPress`, and RNGH's cancellation of the RN
275
+ // responder comes too late to stop it. Measured on a Pixel 10 Pro: ONE tap
276
+ // produced both, ~600ms apart on a busy JS thread, so every consumer got two
277
+ // selections per tap. `TabBarButtonBody` is where that is resolved.
222
278
  const gesture = useMemo(() => {
223
279
  const indexAtX = (x: number, minimizedValue: number) => {
224
280
  'worklet';
@@ -292,8 +348,15 @@ function TabBarBody({
292
348
  const index = Math.round(indexAtX(event.x, progress.value));
293
349
  // Same rule as the controlled path above: appear at the tapped tab when
294
350
  // hidden, slide to it when already on screen.
295
- slideIndex.value =
296
- highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
351
+ //
352
+ // A tap is a discrete writer, so on the DRIVEN path it stands down like
353
+ // the others and the driver carries the highlight to the tapped tab.
354
+ // The scrub is the one gesture that does not: a finger dragging the
355
+ // pill is manipulating the bar itself, not asking for a page.
356
+ if (!driven) {
357
+ slideIndex.value =
358
+ highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
359
+ }
297
360
  highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
298
361
  setMinimized(minimized, 0);
299
362
  runOnJS(selectIndex)(index);
@@ -324,6 +387,7 @@ function TabBarBody({
324
387
  hasLongPress,
325
388
  longPressIndex,
326
389
  tick,
390
+ driven,
327
391
  isDragging,
328
392
  lastTicked,
329
393
  slideIndex,
@@ -444,8 +508,8 @@ function TabBarBody({
444
508
  const constrainedWrapStyle: ViewStyle | null =
445
509
  maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
446
510
  const barContext = useMemo(
447
- () => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
448
- [slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
511
+ () => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex }),
512
+ [slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex],
449
513
  );
450
514
 
451
515
  return (
@@ -493,6 +557,9 @@ interface TabBarButtonBodyProps extends TabBarButtonProps {
493
557
  }
494
558
 
495
559
  /** One tab trigger: icon + label that fades out when the bar minimizes. */
560
+ /** Stable identity: a new array each render would re-send the actions to the OS. */
561
+ const ACTIVATE_ACTIONS = [{ name: 'activate' as const }];
562
+
496
563
  function TabBarButtonBody({
497
564
  Glyph,
498
565
  item,
@@ -522,6 +589,10 @@ function TabBarButtonBody({
522
589
  useEffect(() => {
523
590
  if (isFocused === undefined || !isFocused || !bar) return;
524
591
  if (bar.isDragging.value) return;
592
+ // A driver owns the position — see `TabBarProps.activeProgress`. It is
593
+ // already tracking this same focus change; springing from here as well would
594
+ // be a second writer on one shared value.
595
+ if (bar.driven) return;
525
596
  bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
526
597
  }, [isFocused, index, bar]);
527
598
 
@@ -584,21 +655,43 @@ function TabBarButtonBody({
584
655
  aria-selected={focused}
585
656
  accessibilityLabel={item.label}
586
657
  {...pressableProps}
658
+ // ASSISTIVE TECHNOLOGY, on native, is the one activation the bar's gesture
659
+ // cannot see: TalkBack and VoiceOver activate a view without ever
660
+ // producing a touch, so the `Tap` gesture never runs and the selection has
661
+ // to come from here.
662
+ accessibilityActions={ACTIVATE_ACTIONS}
663
+ onAccessibilityAction={(event) => {
664
+ if (event.nativeEvent.actionName !== 'activate') return;
665
+ if (isFocused === undefined) bar?.selectIndex(index);
666
+ }}
587
667
  onPress={(event) => {
588
668
  // The bar's GestureDetector normally consumes touches; this still fires
589
669
  // for assistive-technology activation (VoiceOver) and keyboard focus.
590
670
  if (bar) {
591
671
  // Appear at the tab when hidden, slide to it when visible — the same
592
- // rule the tap gesture and the controlled path follow.
593
- bar.slideIndex.value =
594
- bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
672
+ // rule the tap gesture and the controlled path follow. Skipped on the
673
+ // driven path, where the position belongs to the driver and this
674
+ // press will reach it as an ordinary selection.
675
+ if (!bar.driven) {
676
+ bar.slideIndex.value =
677
+ bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
678
+ }
595
679
  bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
596
680
  }
597
681
  setMinimized(minimized, 0);
598
- // Controlled path only. On the focus-driven path the trigger's own
599
- // `onPress` below performs the navigation, so reporting the selection
600
- // here as well would navigate twice.
601
- if (isFocused === undefined) bar?.selectIndex(index);
682
+ // Controlled path only, and NOT on Android. On the focus-driven path the
683
+ // trigger's own `onPress` below performs the navigation, so reporting
684
+ // the selection here as well would navigate twice.
685
+ //
686
+ // ANDROID IS THE ONE PLATFORM THAT DOUBLES. RNGH sets
687
+ // `cancelsTouchesInView` on iOS, so a recognised tap cancels this press
688
+ // and the gesture above is the only reporter there; react-native-web
689
+ // likewise routes a keyboard Enter or Space through this press and
690
+ // nothing else. Android cancels nothing — measured on a Pixel 10 Pro,
691
+ // one tap produced `PRESSABLE selectIndex(1)` AND `GESTURE tap
692
+ // selectIndex(1)` ~600ms apart on a busy JS thread — so there, and only
693
+ // there, this press stands down.
694
+ if (isFocused === undefined && Platform.OS !== 'android') bar?.selectIndex(index);
602
695
  onPress?.(event);
603
696
  }}
604
697
  // `Pressable`'s `style` also accepts a function of the press state; both
@@ -19,6 +19,11 @@ import type { TabBarItem, TabBarProps } from '../types';
19
19
  * by each trigger's own focus (see `RouterTabBarButton`), which is what keeps it
20
20
  * correct through deep links, back gestures and any other programmatic
21
21
  * navigation. Setting both would put two writers on one shared value.
22
+ *
23
+ * `activeProgress` is NOT omitted, and that is not an oversight. It answers a
24
+ * different question — where the highlight is, not whether there is one — so a
25
+ * router-focused bar whose screens are PAGED can pass it and keep the focus
26
+ * path for selection. See `TabBarProps.activeProgress`.
22
27
  */
23
28
  export type RouterTabBarProps = TabListProps & Omit<TabBarProps, 'activeIndex'>;
24
29
 
@@ -4,14 +4,21 @@
4
4
  *
5
5
  * Public API types for `@oxyhq/bloom/tab-bar`.
6
6
  *
7
- * This module imports from `react` and `react-native` ONLY. It is resolved by a
8
- * consumer's `tsc`, by Metro, and by every web bundler alike, so a platform
9
- * import here (expo-glass-effect / expo-symbols / expo-router) would break all
10
- * three at once. Platform behaviour lives in `surface.native.tsx` /
11
- * `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter.
7
+ * This module names no PLATFORM package. It is resolved by a consumer's `tsc`,
8
+ * by Metro, and by every web bundler alike, so an import here of
9
+ * expo-glass-effect / expo-symbols / expo-router / react-native-screens would
10
+ * break all three at once. Platform behaviour lives in `surface.native.tsx` /
11
+ * `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter; the
12
+ * gate is `src/__tests__/TabBarWebFork.test.ts`.
13
+ *
14
+ * `react-native-reanimated` is exempt and always was: it is a REQUIRED peer that
15
+ * every neutral module in this family already imports at runtime
16
+ * (`TabBarBase.tsx`), and the one reference below is `import type`, erased
17
+ * before any bundler sees it.
12
18
  */
13
19
  import type { ReactNode } from 'react';
14
20
  import type { PressableProps, ViewProps } from 'react-native';
21
+ import type { SharedValue } from 'react-native-reanimated';
15
22
 
16
23
  export type TabBarItem = {
17
24
  /** Stable identity for the tab (a route name in router-driven usage). */
@@ -92,6 +99,30 @@ export type TabBarProps = ViewProps & {
92
99
  * highlight from its own `isFocused`.
93
100
  */
94
101
  activeIndex?: number;
102
+ /**
103
+ * DRIVEN path — the highlight's live position, in TAB UNITS, on the UI thread.
104
+ * A pager writes its own page position here (`1.4` is 40% of the way from the
105
+ * second tab to the third) and the highlight tracks the finger 1:1 instead of
106
+ * springing to a settled index after the navigation commits.
107
+ *
108
+ * It says WHERE the highlight is, never WHETHER there is one. Selection stays
109
+ * with the path already in play: `activeIndex` when the bar is controlled (an
110
+ * index naming no tab still fades the highlight out — see above), each
111
+ * button's `isFocused` when it is not. Passing this alone, with neither, is a
112
+ * bar that is always selected, which is what the focus-driven path already
113
+ * means.
114
+ *
115
+ * PRECEDENCE, because a shared value with two writers is a race: the SCRUB
116
+ * always wins — a finger on the bar is a direct manipulation of the bar, and
117
+ * releasing it reports through `onIndexChange` like any other selection. Below
118
+ * that, this value wins over every discrete writer: while it is supplied, the
119
+ * controlled effect, the focus effect and a `TabBarButton`'s own press all
120
+ * stop writing the position and leave it to the driver. So a consumer that
121
+ * passes this must keep it truthful — spring it to the settled index after a
122
+ * commit, and after any navigation it did not cause (a deep link, a back
123
+ * gesture), or the highlight stays where the last gesture left it.
124
+ */
125
+ activeProgress?: SharedValue<number>;
95
126
  /**
96
127
  * Called with the chosen index when a tab is picked by tap, by scrub release,
97
128
  * or by keyboard/assistive-technology activation of a `TabBarButton`.
@@ -6,10 +6,25 @@ import { buildTheme } from '../build-theme';
6
6
  import type { AppColorName } from '../color-presets';
7
7
  import { buildNativePresetStyle, buildScopeVars, getVariableContextProvider } from './style-builder';
8
8
 
9
+ /**
10
+ * The variables a scope publishes when it carries no preset of its own.
11
+ *
12
+ * Empty and shared at module scope, both deliberately: react-native-css's
13
+ * `VariableContextProvider` MERGES its value over the inherited one, so an empty
14
+ * record leaves the parent scope's variables exactly as they were, and one
15
+ * shared identity keeps that provider's `useMemo` from recomputing every render.
16
+ */
17
+ const NO_VARS: Record<string, string> = Object.freeze({});
18
+
9
19
  export interface BloomColorScopeProps {
10
20
  /**
11
- * Preset to apply within this subtree. When `undefined`, the scope is a
12
- * no-op and children inherit the parent scope's preset unchanged.
21
+ * Preset to apply within this subtree. When `undefined`, the scope publishes
22
+ * nothing of its own and children inherit the parent scope's preset unchanged.
23
+ *
24
+ * It is a no-op in what it PUBLISHES, never in what it RENDERS — see the
25
+ * component's own comment: the element tree this returns is identical whether
26
+ * or not a preset is set, because a preset that arrives late must not remount
27
+ * the subtree.
13
28
  */
14
29
  colorPreset: AppColorName | undefined;
15
30
  /**
@@ -26,6 +41,35 @@ interface StyleableProps {
26
41
  style?: StyleProp<ViewStyle>;
27
42
  }
28
43
 
44
+ /**
45
+ * A subtree themed by one color preset.
46
+ *
47
+ * THE SHAPE OF WHAT THIS RETURNS DOES NOT DEPEND ON `colorPreset`. Both
48
+ * providers are mounted unconditionally, and an absent preset is expressed as
49
+ * pass-through VALUES ({@link NO_VARS}, the parent's own theme) rather than as a
50
+ * shorter tree. That is a correctness requirement, not a tidiness one, and it
51
+ * cost a real bug to learn:
52
+ *
53
+ * The no-preset path used to be `return <>{children}</>` — two provider levels
54
+ * shallower than the preset path. React reconciles by POSITION, so the first
55
+ * render after a preset arrives finds different element types at that position
56
+ * and unmounts the whole subtree to mount a new one. Every consumer that resolves
57
+ * its preset asynchronously — a profile screen learning the account's color a
58
+ * few hundred milliseconds after it opens — therefore remounted everything below
59
+ * the scope, including its own navigator.
60
+ *
61
+ * In Mention that scope sits above the app's route stack: the remount rebuilt
62
+ * the `[username]` navigator from scratch, which dropped the pushed child route
63
+ * AND its `username` param, and the profile a reader had just tapped rendered
64
+ * itself as "Profile not found" (mention#…, reproduced from the reel on a Pixel
65
+ * 10 Pro; the A/B was pinning `colorPreset` and watching the collapse stop).
66
+ * Remounting also throws away scroll positions, video players and in-flight
67
+ * state, so the same bug was costing far more than the one visible symptom.
68
+ *
69
+ * If a future change wants the no-preset case to render less, it has to keep the
70
+ * element types at this position identical across a preset change, or the
71
+ * remount comes back.
72
+ */
29
73
  export function BloomColorScope({
30
74
  colorPreset,
31
75
  asChild = false,
@@ -33,10 +77,9 @@ export function BloomColorScope({
33
77
  children,
34
78
  }: BloomColorScopeProps) {
35
79
  // All hooks are called UNCONDITIONALLY, in the same order on every render —
36
- // never gate a hook behind an early return (rules of hooks). The conditional
37
- // no-op/throw behavior is applied AFTER every hook has run, using the values
38
- // the hooks produced. `resolvedMode` falls back harmlessly when the provider
39
- // is absent (that render path throws below anyway).
80
+ // never gate a hook behind an early return (rules of hooks). The throw below
81
+ // happens AFTER every hook has run. `resolvedMode` falls back harmlessly when
82
+ // the provider is absent (that render path throws anyway).
40
83
  const parent = useContext(BloomThemeContext);
41
84
  const resolvedMode = parent?.theme.mode ?? 'light';
42
85
 
@@ -58,10 +101,6 @@ export function BloomColorScope({
58
101
  if (!parent) {
59
102
  throw new Error('BloomColorScope must be used within a <BloomThemeProvider>');
60
103
  }
61
- // `colorPreset` undefined => the scope is a no-op; children inherit the
62
- // parent scope. With `parent` present, `contextValue`/`nativeVars` are null
63
- // iff `colorPreset` is absent, so this guard also narrows them to non-null.
64
- if (!contextValue || !nativeVars) return <>{children}</>;
65
104
 
66
105
  const VariableProvider = getVariableContextProvider();
67
106
 
@@ -85,12 +124,14 @@ export function BloomColorScope({
85
124
  // Wrap the subtree in the variable provider when NativeWind is available;
86
125
  // otherwise render as-is so we never crash on web/non-NativeWind hosts.
87
126
  const scoped = VariableProvider ? (
88
- <VariableProvider value={nativeVars}>{content}</VariableProvider>
127
+ <VariableProvider value={nativeVars ?? NO_VARS}>{content}</VariableProvider>
89
128
  ) : (
90
129
  content
91
130
  );
92
131
 
93
- return <BloomThemeContext.Provider value={contextValue}>{scoped}</BloomThemeContext.Provider>;
132
+ return (
133
+ <BloomThemeContext.Provider value={contextValue ?? parent}>{scoped}</BloomThemeContext.Provider>
134
+ );
94
135
  }
95
136
 
96
137
  /**
@@ -20,8 +20,12 @@ function buildWebScopeVars(
20
20
 
21
21
  export interface BloomColorScopeProps {
22
22
  /**
23
- * Preset to apply within this subtree. When `undefined`, the scope is a
24
- * no-op and children inherit the parent scope's preset unchanged.
23
+ * Preset to apply within this subtree. When `undefined`, the scope publishes
24
+ * nothing of its own and children inherit the parent scope's preset unchanged.
25
+ *
26
+ * It is a no-op in what it PUBLISHES, never in what it RENDERS — the element
27
+ * tree is identical either way, so a preset that arrives late cannot remount
28
+ * the subtree. `ColorScope.tsx` carries the full account.
25
29
  */
26
30
  colorPreset: AppColorName | undefined;
27
31
  /**
@@ -91,10 +95,6 @@ export function BloomColorScope({
91
95
  if (!parent) {
92
96
  throw new Error('BloomColorScope must be used within a <BloomThemeProvider>');
93
97
  }
94
- // `colorPreset` undefined => the scope is a no-op; children inherit the
95
- // parent scope. With `parent` present, `contextValue`/`varsStyle` are null
96
- // iff `colorPreset` is absent, so this guard also narrows them to non-null.
97
- if (!contextValue || !varsStyle) return <>{children}</>;
98
98
 
99
99
  let content: React.ReactNode;
100
100
  if (asChild) {
@@ -111,7 +111,7 @@ export function BloomColorScope({
111
111
  const childStyle = child.props.style;
112
112
  const childIsRnStyled = Array.isArray(childStyle) || typeof childStyle === 'number';
113
113
  const mergedStyle: WebStyle = childIsRnStyled
114
- ? [varsStyle, style, childStyle]
114
+ ? [varsStyle ?? undefined, style, childStyle]
115
115
  : { ...varsStyle, ...style, ...(childStyle || undefined) };
116
116
  content = cloneElement(child, { style: mergedStyle });
117
117
  } else {
@@ -121,7 +121,9 @@ export function BloomColorScope({
121
121
  content = <div style={mergedStyle}>{children}</div>;
122
122
  }
123
123
 
124
- return <BloomThemeContext.Provider value={contextValue}>{content}</BloomThemeContext.Provider>;
124
+ return (
125
+ <BloomThemeContext.Provider value={contextValue ?? parent}>{content}</BloomThemeContext.Provider>
126
+ );
125
127
  }
126
128
 
127
129
  /**