@oxyhq/bloom 1.14.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 (55) hide show
  1. package/docs/media-flight.mdx +15 -6
  2. package/lib/commonjs/media-flight/MediaSurface.js +51 -10
  3. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  4. package/lib/commonjs/media-flight/constants.js +32 -7
  5. package/lib/commonjs/media-flight/constants.js.map +1 -1
  6. package/lib/commonjs/tab-bar/TabBarBase.js +34 -6
  7. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  8. package/lib/commonjs/theme/color-scope/ColorScope.js +42 -12
  9. package/lib/commonjs/theme/color-scope/ColorScope.js.map +1 -1
  10. package/lib/commonjs/theme/color-scope/ColorScope.web.js +2 -8
  11. package/lib/commonjs/theme/color-scope/ColorScope.web.js.map +1 -1
  12. package/lib/module/media-flight/MediaSurface.js +52 -11
  13. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  14. package/lib/module/media-flight/constants.js +31 -6
  15. package/lib/module/media-flight/constants.js.map +1 -1
  16. package/lib/module/tab-bar/TabBarBase.js +35 -7
  17. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  18. package/lib/module/theme/color-scope/ColorScope.js +45 -13
  19. package/lib/module/theme/color-scope/ColorScope.js.map +1 -1
  20. package/lib/module/theme/color-scope/ColorScope.web.js +3 -9
  21. package/lib/module/theme/color-scope/ColorScope.web.js.map +1 -1
  22. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/media-flight/constants.d.ts +30 -6
  24. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/media-flight/types.d.ts +7 -2
  26. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/theme/color-scope/ColorScope.d.ts +36 -2
  29. package/lib/typescript/commonjs/theme/color-scope/ColorScope.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts +6 -2
  31. package/lib/typescript/commonjs/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  32. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  33. package/lib/typescript/module/media-flight/constants.d.ts +30 -6
  34. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -1
  35. package/lib/typescript/module/media-flight/types.d.ts +7 -2
  36. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  37. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  38. package/lib/typescript/module/theme/color-scope/ColorScope.d.ts +36 -2
  39. package/lib/typescript/module/theme/color-scope/ColorScope.d.ts.map +1 -1
  40. package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts +6 -2
  41. package/lib/typescript/module/theme/color-scope/ColorScope.web.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  44. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  45. package/src/__tests__/support/press-host.ts +30 -0
  46. package/src/__tests__/support/rendered-style.ts +99 -0
  47. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  48. package/src/media-flight/MediaSurface.tsx +60 -14
  49. package/src/media-flight/constants.ts +31 -6
  50. package/src/media-flight/types.ts +7 -2
  51. package/src/tab-bar/TabBarBase.tsx +41 -7
  52. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
  53. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
  54. package/src/theme/color-scope/ColorScope.tsx +53 -12
  55. package/src/theme/color-scope/ColorScope.web.tsx +10 -8
@@ -25,7 +25,7 @@ import {
25
25
  import { Image } from 'expo-image';
26
26
  import Animated from 'react-native-reanimated';
27
27
 
28
- import { SLOT_IDENTITY_CHURN_LIMIT } from './constants';
28
+ import { SLOT_IDENTITY_CHURN_LIMIT, SLOT_IDENTITY_CHURN_WINDOW_MS } from './constants';
29
29
  import { handOffFlight } from './store';
30
30
  import type { MediaSurfaceContent, MediaVideoSlot } from './types';
31
31
  import {
@@ -149,17 +149,61 @@ export const MediaSurface = memo(function MediaSurface({
149
149
  // supports.
150
150
  const [mountedSurfaceType] = useState(surfaceType);
151
151
 
152
- // A slot rebuilt on every render republishes this surface to the layer on
153
- // every render, which nothing at runtime reports the picture is correct and
154
- // the app is doing work in proportion to how often its rows re-render. The
155
- // counter lives in an effect rather than in render: a render-phase ref write
156
- // makes the React Compiler bail on the whole component.
157
- const slotChurn = useRef(0);
152
+ // A slot rebuilt on every render re-renders this surface and on web
153
+ // republishes the shared node every time the row it lives in renders, which
154
+ // nothing at runtime reports: the picture is correct and the app is just
155
+ // doing work in proportion to its render count.
156
+ //
157
+ // What is counted is a RUN of consecutive commits that each carried a new
158
+ // slot, NOT how many times the slot has ever changed. The lifetime count
159
+ // cannot tell the defect from a correctly memoised slot: Bloom's own
160
+ // documented example lists `isWatched` in its deps, so its identity changes
161
+ // once per activation of a long-lived surface, and any such consumer reaches
162
+ // any fixed lifetime total eventually. It would then be told to do the thing
163
+ // it is already doing, with no way to make the warning stop — which is worse
164
+ // than not warning, because the advice is unsatisfiable. A slot rebuilt per
165
+ // render is different in kind: it is new on EVERY commit, including the ones
166
+ // where nothing it reads changed.
167
+ //
168
+ // The run must also be FAST, and that second condition is what covers the
169
+ // consumer who memoised everything else too. With `content` and the rest
170
+ // stable, `memo` skips the renders where only the parent changed, so this
171
+ // component commits ONLY when the slot changes — every commit carries a new
172
+ // slot and the run never breaks, even though there is nothing to fix and no
173
+ // cost to pay. Rate is what tells them apart, and it is what the warning was
174
+ // ever about: a slot rebuilt per render churns as fast as its row renders,
175
+ // while one rebuilt when its state changes moves at the speed of a finger.
176
+ //
177
+ // All three refs are written from effects rather than in render: a
178
+ // render-phase ref write makes the React Compiler bail on the whole
179
+ // component. The first effect fires only when the identity changed, and the
180
+ // second — declared after it, and with no dependency array, so it runs on
181
+ // every commit — is what reads that mark and either extends the run or
182
+ // clears it.
183
+ const slotChangedInCommit = useRef(false);
184
+ const slotChurnRun = useRef(0);
185
+ const slotChurnRunStartedAt = useRef(0);
158
186
  useEffect(() => {
159
- if (renderVideo === undefined) return;
160
- slotChurn.current += 1;
161
- if (slotChurn.current === SLOT_IDENTITY_CHURN_LIMIT) warnSlotNotMemoised();
187
+ slotChangedInCommit.current = true;
162
188
  }, [renderVideo]);
189
+ useEffect(() => {
190
+ if (renderVideo === undefined || !slotChangedInCommit.current) {
191
+ slotChurnRun.current = 0;
192
+ return;
193
+ }
194
+ slotChangedInCommit.current = false;
195
+ const now = Date.now();
196
+ if (
197
+ slotChurnRun.current === 0 ||
198
+ now - slotChurnRunStartedAt.current > SLOT_IDENTITY_CHURN_WINDOW_MS
199
+ ) {
200
+ slotChurnRun.current = 1;
201
+ slotChurnRunStartedAt.current = now;
202
+ return;
203
+ }
204
+ slotChurnRun.current += 1;
205
+ if (slotChurnRun.current === SLOT_IDENTITY_CHURN_LIMIT) warnSlotNotMemoised();
206
+ });
163
207
 
164
208
  // Both arms report the same fact — "there is a picture here now" — because the
165
209
  // destination of a flight can be either, and a caller should not have to know
@@ -252,10 +296,12 @@ function warnSlotNotMemoised(): void {
252
296
  hasWarnedAboutSlotChurn = true;
253
297
  // eslint-disable-next-line no-console
254
298
  console.warn(
255
- `[Bloom] A \`renderVideo\` slot changed identity ${SLOT_IDENTITY_CHURN_LIMIT} times. ` +
256
- 'It is part of what a media host publishes to the flight layer and is compared by ' +
257
- 'identity, so a slot rebuilt on every render republishes the surface on every render. ' +
258
- 'Wrap it in `useCallback` with the props it actually reads.',
299
+ `[Bloom] A \`renderVideo\` slot was a NEW function on ${SLOT_IDENTITY_CHURN_LIMIT} ` +
300
+ 'consecutive renders, including ones where nothing it reads changed. The slot is ' +
301
+ 'compared by identity on web it is part of what a media host publishes to the ' +
302
+ 'flight layer so one rebuilt per render re-renders the surface per render. Wrap ' +
303
+ 'it in `useCallback` with the props it actually reads. A slot that changes when ' +
304
+ 'those props change is fine and does not reach this.',
259
305
  );
260
306
  }
261
307
 
@@ -32,16 +32,41 @@ export const SURFACE_MOUNT_TIMEOUT_MS = 250;
32
32
  */
33
33
 
34
34
  /**
35
- * How many times a video slot may change identity before Bloom says so.
35
+ * How many CONSECUTIVE renders a video slot may arrive new on before Bloom says
36
+ * so.
36
37
  *
37
- * `renderVideo` is part of what a host publishes to the layer and is compared
38
- * by identity, so an unmemoised slot republishes on every render of the row it
39
- * lives in silently, and only under load. Five is well past any legitimate
40
- * cause (a mute toggle, a fit change) and far below the churn of a slot rebuilt
41
- * per render in a scrolling feed.
38
+ * `renderVideo` is compared by identity and on web it is part of what a host
39
+ * publishes to the layer — so an unmemoised slot re-renders the surface on
40
+ * every render of the row it lives in, silently and only under load.
41
+ *
42
+ * Consecutive, never a lifetime total. A correctly memoised slot changes
43
+ * identity whenever the state it reads changes (`isWatched` on a reel slide,
44
+ * say), so ANY fixed lifetime total is reached eventually by a consumer with
45
+ * nothing to fix — and the warning's own advice, `useCallback` over the props
46
+ * the slot reads, is what such a consumer already wrote. Five in a row is
47
+ * unreachable that way: it takes five commits where nothing the slot reads
48
+ * changed and the slot was rebuilt anyway.
42
49
  */
43
50
  export const SLOT_IDENTITY_CHURN_LIMIT = 5;
44
51
 
52
+ /**
53
+ * How long a run of {@link SLOT_IDENTITY_CHURN_LIMIT} new slots may take and
54
+ * still be reported (ms).
55
+ *
56
+ * "Consecutive" alone is not enough for the consumer who memoised the surface's
57
+ * other props as well: `memo` then skips every render where only the parent
58
+ * changed, so the surface commits ONLY when the slot changes and the run never
59
+ * breaks — with nothing to fix and, because the renders were skipped, no cost
60
+ * paid either.
61
+ *
62
+ * Rate is what separates them, and it is what the warning was always about. A
63
+ * slot rebuilt per render changes as fast as its row renders, which in a
64
+ * scrolling feed is five times in a fraction of a frame budget. A slot rebuilt
65
+ * when the state it reads changes moves at the speed of a finger, and half a
66
+ * second is far too short for five activations of ONE surface.
67
+ */
68
+ export const SLOT_IDENTITY_CHURN_WINDOW_MS = 500;
69
+
45
70
  /**
46
71
  * How far a media element may sit from the box it is supposed to fill, in px,
47
72
  * before Bloom says so.
@@ -276,9 +276,14 @@ export interface MediaVideoSlotProps<P extends VideoPlayerLike = VideoPlayerLike
276
276
  * So the consumer may build the element instead, with Bloom deciding only what
277
277
  * it has to: which player, and a style that fills the box.
278
278
  *
279
- * MEMOISE IT (`useCallback`). The slot is part of what a host publishes to the
280
- * layer, compared by identity — a new function every render republishes every
279
+ * MEMOISE IT (`useCallback`), over the state the element actually reads. The
280
+ * slot is compared by identity — and on web it is part of what a host publishes
281
+ * to the layer — so a new function every render re-renders the surface every
281
282
  * render. Correct either way, wasteful if you skip it.
283
+ *
284
+ * It is expected to change when that state does; only a slot rebuilt on renders
285
+ * where nothing it reads changed is the mistake, and that is the only one Bloom
286
+ * warns about.
282
287
  */
283
288
  export type MediaVideoSlot<P extends VideoPlayerLike = VideoPlayerLike> = (
284
289
  props: MediaVideoSlotProps<P>,
@@ -22,7 +22,14 @@ 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,
@@ -260,8 +267,14 @@ function TabBarBody({
260
267
  // Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
261
268
  // it must feel attached), haptic ticks fire on boundary crossings, and
262
269
  // navigation happens only on release. Taps are handled by a Tap gesture
263
- // racing the pan — the detector consumes the bar's touches, so the inner
264
- // 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.
265
278
  const gesture = useMemo(() => {
266
279
  const indexAtX = (x: number, minimizedValue: number) => {
267
280
  'worklet';
@@ -544,6 +557,9 @@ interface TabBarButtonBodyProps extends TabBarButtonProps {
544
557
  }
545
558
 
546
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
+
547
563
  function TabBarButtonBody({
548
564
  Glyph,
549
565
  item,
@@ -639,6 +655,15 @@ function TabBarButtonBody({
639
655
  aria-selected={focused}
640
656
  accessibilityLabel={item.label}
641
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
+ }}
642
667
  onPress={(event) => {
643
668
  // The bar's GestureDetector normally consumes touches; this still fires
644
669
  // for assistive-technology activation (VoiceOver) and keyboard focus.
@@ -654,10 +679,19 @@ function TabBarButtonBody({
654
679
  bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
655
680
  }
656
681
  setMinimized(minimized, 0);
657
- // Controlled path only. On the focus-driven path the trigger's own
658
- // `onPress` below performs the navigation, so reporting the selection
659
- // here as well would navigate twice.
660
- 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);
661
695
  onPress?.(event);
662
696
  }}
663
697
  // `Pressable`'s `style` also accepts a function of the press state; both