@oxyhq/bloom 1.3.0 → 1.5.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 (68) hide show
  1. package/docs/media-flight.mdx +77 -1
  2. package/lib/commonjs/media-flight/MediaFlightHost.js +2 -0
  3. package/lib/commonjs/media-flight/MediaFlightHost.js.map +1 -1
  4. package/lib/commonjs/media-flight/MediaFlightHost.web.js +3 -1
  5. package/lib/commonjs/media-flight/MediaFlightHost.web.js.map +1 -1
  6. package/lib/commonjs/media-flight/MediaFlightLayer.web.js +49 -24
  7. package/lib/commonjs/media-flight/MediaFlightLayer.web.js.map +1 -1
  8. package/lib/commonjs/media-flight/MediaSurface.js +38 -5
  9. package/lib/commonjs/media-flight/MediaSurface.js.map +1 -1
  10. package/lib/commonjs/media-flight/constants.js +23 -1
  11. package/lib/commonjs/media-flight/constants.js.map +1 -1
  12. package/lib/commonjs/media-flight/media-node.web.js +29 -6
  13. package/lib/commonjs/media-flight/media-node.web.js.map +1 -1
  14. package/lib/commonjs/media-flight/store.js +4 -1
  15. package/lib/commonjs/media-flight/store.js.map +1 -1
  16. package/lib/module/media-flight/MediaFlightHost.js +2 -0
  17. package/lib/module/media-flight/MediaFlightHost.js.map +1 -1
  18. package/lib/module/media-flight/MediaFlightHost.web.js +3 -1
  19. package/lib/module/media-flight/MediaFlightHost.web.js.map +1 -1
  20. package/lib/module/media-flight/MediaFlightLayer.web.js +50 -25
  21. package/lib/module/media-flight/MediaFlightLayer.web.js.map +1 -1
  22. package/lib/module/media-flight/MediaSurface.js +39 -6
  23. package/lib/module/media-flight/MediaSurface.js.map +1 -1
  24. package/lib/module/media-flight/constants.js +22 -0
  25. package/lib/module/media-flight/constants.js.map +1 -1
  26. package/lib/module/media-flight/media-node.web.js +29 -6
  27. package/lib/module/media-flight/media-node.web.js.map +1 -1
  28. package/lib/module/media-flight/store.js +4 -1
  29. package/lib/module/media-flight/store.js.map +1 -1
  30. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts +1 -1
  31. package/lib/typescript/commonjs/media-flight/MediaFlightHost.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts +1 -1
  33. package/lib/typescript/commonjs/media-flight/MediaFlightHost.web.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts +13 -2
  36. package/lib/typescript/commonjs/media-flight/MediaSurface.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/media-flight/constants.d.ts +20 -0
  38. package/lib/typescript/commonjs/media-flight/constants.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts +17 -3
  40. package/lib/typescript/commonjs/media-flight/media-node.web.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/media-flight/store.d.ts +4 -1
  42. package/lib/typescript/commonjs/media-flight/store.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/media-flight/types.d.ts +49 -0
  44. package/lib/typescript/commonjs/media-flight/types.d.ts.map +1 -1
  45. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts +1 -1
  46. package/lib/typescript/module/media-flight/MediaFlightHost.d.ts.map +1 -1
  47. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts +1 -1
  48. package/lib/typescript/module/media-flight/MediaFlightHost.web.d.ts.map +1 -1
  49. package/lib/typescript/module/media-flight/MediaFlightLayer.web.d.ts.map +1 -1
  50. package/lib/typescript/module/media-flight/MediaSurface.d.ts +13 -2
  51. package/lib/typescript/module/media-flight/MediaSurface.d.ts.map +1 -1
  52. package/lib/typescript/module/media-flight/constants.d.ts +20 -0
  53. package/lib/typescript/module/media-flight/constants.d.ts.map +1 -1
  54. package/lib/typescript/module/media-flight/media-node.web.d.ts +17 -3
  55. package/lib/typescript/module/media-flight/media-node.web.d.ts.map +1 -1
  56. package/lib/typescript/module/media-flight/store.d.ts +4 -1
  57. package/lib/typescript/module/media-flight/store.d.ts.map +1 -1
  58. package/lib/typescript/module/media-flight/types.d.ts +49 -0
  59. package/lib/typescript/module/media-flight/types.d.ts.map +1 -1
  60. package/package.json +1 -1
  61. package/src/media-flight/MediaFlightHost.tsx +2 -0
  62. package/src/media-flight/MediaFlightHost.web.tsx +3 -2
  63. package/src/media-flight/MediaFlightLayer.web.tsx +58 -38
  64. package/src/media-flight/MediaSurface.tsx +62 -8
  65. package/src/media-flight/constants.ts +22 -0
  66. package/src/media-flight/media-node.web.ts +48 -10
  67. package/src/media-flight/store.ts +4 -1
  68. package/src/media-flight/types.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "packageManager": "bun@1.3.14",
5
5
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
6
6
  "main": "lib/commonjs/index.js",
@@ -23,6 +23,7 @@ export function MediaFlightHost({
23
23
  content,
24
24
  style,
25
25
  contentFit = 'cover',
26
+ renderVideo,
26
27
  nativeControls = false,
27
28
  accessibilityLabel,
28
29
  surfaceType,
@@ -41,6 +42,7 @@ export function MediaFlightHost({
41
42
  <MediaSurface
42
43
  content={content}
43
44
  contentFit={contentFit}
45
+ renderVideo={renderVideo}
44
46
  nativeControls={nativeControls}
45
47
  accessibilityLabel={accessibilityLabel}
46
48
  surfaceType={surfaceType}
@@ -30,6 +30,7 @@ export function MediaFlightHost({
30
30
  content,
31
31
  style,
32
32
  contentFit = 'cover',
33
+ renderVideo,
33
34
  nativeControls = false,
34
35
  accessibilityLabel,
35
36
  pointerEvents,
@@ -48,8 +49,8 @@ export function MediaFlightHost({
48
49
  );
49
50
 
50
51
  const render = useMemo<MediaNodeRender>(
51
- () => ({ content, contentFit, nativeControls, accessibilityLabel, flightId }),
52
- [content, contentFit, nativeControls, accessibilityLabel, flightId],
52
+ () => ({ content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId }),
53
+ [content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId],
53
54
  );
54
55
  const renderRef = useRef(render);
55
56
 
@@ -30,8 +30,6 @@ import {
30
30
  useCallback,
31
31
  useEffect,
32
32
  useLayoutEffect,
33
- useMemo,
34
- useRef,
35
33
  useState,
36
34
  useSyncExternalStore,
37
35
  } from 'react';
@@ -39,6 +37,7 @@ import { StyleSheet, type View } from 'react-native';
39
37
  import { createPortal } from 'react-dom';
40
38
  import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
41
39
 
40
+ import { SLOT_FILL_TOLERANCE_PX } from './constants';
42
41
  import { OverlayRoot } from '../overlay';
43
42
  import { Portal } from '../portal/index.web';
44
43
  import { MediaSurface, type MediaSurfaceStyle } from './MediaSurface';
@@ -48,7 +47,6 @@ import {
48
47
  getMediaNodes,
49
48
  releaseMediaNode,
50
49
  subscribeToMediaNodes,
51
- type MediaNodeRender,
52
50
  type MediaNodeView,
53
51
  } from './media-node.web';
54
52
  import { getFlights, notifySurfaceMounted, subscribeToFlights } from './store';
@@ -91,11 +89,28 @@ MediaFlightLayer.displayName = 'MediaFlightLayer';
91
89
 
92
90
  /** One media element, painted into the wrapper its id owns, and never again. */
93
91
  function SharedMediaNode({ node }: { node: MediaNodeView }) {
94
- const { content, contentFit, nativeControls, accessibilityLabel, flightId } = node.render;
92
+ const { content, contentFit, renderVideo, nativeControls, accessibilityLabel, flightId } = node.render;
93
+
94
+ // DOES THE MEDIA ACTUALLY FILL ITS BOX?
95
+ //
96
+ // Bloom hands a `renderVideo` slot a style that sizes a replaced element, and
97
+ // a slot that forgets to spread it paints a 300x150 `<video>` inside whatever
98
+ // box it was given — correct in every other respect, wrong on screen, and
99
+ // reported by nothing. That defect shipped once from Bloom's own view and
100
+ // cost a day to find; a consumer writing the slot can reproduce it in a line.
101
+ //
102
+ // Measured one frame after the commit, because layout has to have happened.
103
+ useEffect(() => {
104
+ if (process.env.NODE_ENV === 'production' || renderVideo === undefined) return undefined;
105
+ const frame = requestAnimationFrame(() => warnIfNotFilling(node.wrapper));
106
+ return () => cancelAnimationFrame(frame);
107
+ }, [node.wrapper, renderVideo]);
108
+
95
109
  return createPortal(
96
110
  <MediaSurface
97
111
  content={content}
98
112
  contentFit={contentFit}
113
+ renderVideo={renderVideo}
99
114
  nativeControls={nativeControls}
100
115
  accessibilityLabel={accessibilityLabel}
101
116
  flightId={flightId}
@@ -105,6 +120,37 @@ function SharedMediaNode({ node }: { node: MediaNodeView }) {
105
120
  );
106
121
  }
107
122
 
123
+ let hasWarnedAboutFill = false;
124
+
125
+ /**
126
+ * One warning per module lifetime, and none in production — same mechanism as
127
+ * `warnExpoVideoUnavailable`.
128
+ *
129
+ * It compares the media element with the WRAPPER, not with a fixed size: the
130
+ * invariant is "the media fills the box it was given", and a box that happens
131
+ * to be 300x150 is not a defect.
132
+ */
133
+ function warnIfNotFilling(wrapper: HTMLElement): void {
134
+ if (hasWarnedAboutFill || !wrapper.isConnected) return;
135
+ const media = wrapper.querySelector('video');
136
+ if (media === null) return;
137
+ const box = wrapper.getBoundingClientRect();
138
+ const painted = media.getBoundingClientRect();
139
+ if (box.width === 0 || box.height === 0) return;
140
+ const off =
141
+ Math.abs(painted.width - box.width) > SLOT_FILL_TOLERANCE_PX ||
142
+ Math.abs(painted.height - box.height) > SLOT_FILL_TOLERANCE_PX;
143
+ if (!off) return;
144
+ hasWarnedAboutFill = true;
145
+ // eslint-disable-next-line no-console
146
+ console.warn(
147
+ `[Bloom] A \`renderVideo\` element is ${Math.round(painted.width)}x${Math.round(painted.height)} ` +
148
+ `inside a ${Math.round(box.width)}x${Math.round(box.height)} box. A DOM \`<video>\` is a ` +
149
+ 'REPLACED element: without an explicit size it paints at its intrinsic 300x150 whatever ' +
150
+ 'its inset says. Spread the `style` Bloom passes the slot.',
151
+ );
152
+ }
153
+
108
154
  /**
109
155
  * One flying surface. See `MediaFlightLayer.tsx` for why the size is animated
110
156
  * rather than scaled.
@@ -137,21 +183,9 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
137
183
 
138
184
  const box = [styles.box, { left: to.x, top: to.y, borderRadius: cornerRadius }, boxStyle];
139
185
 
140
- const render = useMemo<MediaNodeRender>(
141
- () => ({
142
- content,
143
- contentFit,
144
- nativeControls: false,
145
- accessibilityLabel: undefined,
146
- // A flying surface never hands off to itself.
147
- flightId: undefined,
148
- }),
149
- [content, contentFit],
150
- );
151
-
152
186
  // The shared node is already painting this media. The flight is then a BOX
153
187
  // that borrows it for the length of the leg, never a second copy.
154
- if (shared) return <FlyingSharedNode id={id} style={box} render={render} />;
188
+ if (shared) return <FlyingSharedNode id={id} style={box} />;
155
189
 
156
190
  return (
157
191
  <MediaSurface
@@ -178,33 +212,19 @@ function MediaFlightSurface({ flight, shared }: { flight: MediaFlight; shared: b
178
212
  * is inert here — a descendant asking for `auto` does not override a `none`
179
213
  * ancestor.
180
214
  */
181
- function FlyingSharedNode({
182
- id,
183
- style,
184
- render,
185
- }: {
186
- id: string;
187
- style: MediaSurfaceStyle;
188
- render: MediaNodeRender;
189
- }) {
215
+ function FlyingSharedNode({ id, style }: { id: string; style: MediaSurfaceStyle }) {
190
216
  const [node, setNode] = useState<View | null>(null);
191
217
  const setBox = useCallback((next: View | null) => setNode(next), []);
192
- const renderRef = useRef(render);
193
-
194
- // Declared first so it runs first on mount, and afterwards is what pushes
195
- // prop changes into a live claim IN PLACE — releasing and re-claiming would
196
- // move the node away and back, and a move away is a removal.
197
- useLayoutEffect(() => {
198
- renderRef.current = render;
199
- const el = node as unknown as HTMLElement | null;
200
- if (el !== null) claimMediaNode(id, el, FLIGHT_RANK, render);
201
- }, [id, node, render]);
202
218
 
203
- // Owns the claim's LIFETIME, and only that.
219
+ // A POSITION-ONLY claim: it says where the node lives for the length of the
220
+ // leg and nothing about what is painted in it. A flight that published a
221
+ // render of its own would replace the hosts' — and swap a consumer's own
222
+ // element out and back, which is two remounts in the middle of the one
223
+ // operation whose whole point is that the element is never rebuilt.
204
224
  useLayoutEffect(() => {
205
225
  const el = node as unknown as HTMLElement | null;
206
226
  if (el === null) return undefined;
207
- claimMediaNode(id, el, FLIGHT_RANK, renderRef.current);
227
+ claimMediaNode(id, el, FLIGHT_RANK);
208
228
  return () => releaseMediaNode(id, el, FLIGHT_RANK);
209
229
  }, [id, node]);
210
230
 
@@ -13,7 +13,7 @@
13
13
  * platform video surface the way it rounds an image, and the transform lands on
14
14
  * a node reanimated is allowed to drive (expo-video's own host is not one).
15
15
  */
16
- import { memo, useCallback, useState, type ComponentProps } from 'react';
16
+ import { memo, useCallback, useEffect, useRef, useState, type ComponentProps } from 'react';
17
17
  import {
18
18
  Platform,
19
19
  StyleSheet,
@@ -25,8 +25,9 @@ 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
29
  import { handOffFlight } from './store';
29
- import type { MediaSurfaceContent } from './types';
30
+ import type { MediaSurfaceContent, MediaVideoSlot } from './types';
30
31
  import {
31
32
  loadExpoVideo,
32
33
  warnExpoVideoUnavailable,
@@ -70,6 +71,14 @@ export interface MediaSurfaceProps {
70
71
  * is every property a flying box relies on.
71
72
  */
72
73
  surfaceType?: VideoSurfaceType;
74
+ /**
75
+ * Paint the video yourself, keeping your own `ref` and expo-video props.
76
+ *
77
+ * When this is given, Bloom builds no `VideoView` at all and the optional
78
+ * peer is never loaded on this path — the consumer already imported
79
+ * expo-video to write the slot. See {@link MediaVideoSlot}.
80
+ */
81
+ renderVideo?: MediaVideoSlot;
73
82
  /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
74
83
  nativeControls?: boolean;
75
84
  accessibilityLabel?: string;
@@ -107,7 +116,10 @@ export interface MediaSurfaceProps {
107
116
  * algorithm pauses WITHOUT firing `pause` (measured in real Chrome, with
108
117
  * controls), so the element goes quiet and its later removal fires nothing.
109
118
  * Unbinding alone does not help: `unmountVideoView` never clears the handler
110
- * it installed. See `releaseFlight`.
119
+ * it installed. See `releaseFlight`, and
120
+ * `scripts/probe-expo-video-listener-leak.mjs`, which measures that in real
121
+ * Chrome against expo-video's real class — including that grafting the
122
+ * missing `_removeListeners` into it makes the leak go away.
111
123
  */
112
124
  detached?: boolean;
113
125
  }
@@ -125,6 +137,7 @@ export const MediaSurface = memo(function MediaSurface({
125
137
  style,
126
138
  contentFit = 'contain',
127
139
  surfaceType = 'textureView',
140
+ renderVideo,
128
141
  nativeControls = false,
129
142
  accessibilityLabel,
130
143
  pointerEvents,
@@ -136,6 +149,18 @@ export const MediaSurface = memo(function MediaSurface({
136
149
  // supports.
137
150
  const [mountedSurfaceType] = useState(surfaceType);
138
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);
158
+ useEffect(() => {
159
+ if (renderVideo === undefined) return;
160
+ slotChurn.current += 1;
161
+ if (slotChurn.current === SLOT_IDENTITY_CHURN_LIMIT) warnSlotNotMemoised();
162
+ }, [renderVideo]);
163
+
139
164
  // Both arms report the same fact — "there is a picture here now" — because the
140
165
  // destination of a flight can be either, and a caller should not have to know
141
166
  // which one it wired. expo-video raises `onFirstFrameRender` from `loadeddata`
@@ -145,10 +170,12 @@ export const MediaSurface = memo(function MediaSurface({
145
170
  }, [flightId]);
146
171
 
147
172
  const still = content.kind === 'video' ? content.poster : content.uri;
148
- // Loaded for the video arm only, and only when there is one an image
149
- // surface must never make an app resolve an optional native peer.
150
- const expoVideo = content.kind === 'video' ? loadExpoVideo() : null;
151
- if (content.kind === 'video' && expoVideo === null) {
173
+ // Loaded for the video arm only, and only when Bloom is the one building the
174
+ // element — an image surface must never make an app resolve an optional
175
+ // native peer, and neither must a consumer that brought its own view.
176
+ const expoVideo =
177
+ content.kind === 'video' && renderVideo === undefined ? loadExpoVideo() : null;
178
+ if (content.kind === 'video' && renderVideo === undefined && expoVideo === null) {
152
179
  // Degrade to the poster rather than to nothing: a black hole where a video
153
180
  // should be reads as a broken app, a still frame reads as a video that has
154
181
  // not started. The warning is what makes the difference visible to the
@@ -178,7 +205,16 @@ export const MediaSurface = memo(function MediaSurface({
178
205
  {...webDraggableProps}
179
206
  />
180
207
  )}
181
- {content.kind === 'video' && expoVideo !== null ? (
208
+ {content.kind === 'video' && renderVideo !== undefined
209
+ ? renderVideo({
210
+ player: detached ? null : content.player,
211
+ // The same style Bloom's own view gets, for the same reason: a
212
+ // replaced element that is not told its size paints at 300x150.
213
+ style: [StyleSheet.absoluteFill, styles.fillReplaced],
214
+ contentFit,
215
+ })
216
+ : null}
217
+ {content.kind === 'video' && renderVideo === undefined && expoVideo !== null ? (
182
218
  <expoVideo.VideoView
183
219
  player={detached ? null : content.player}
184
220
  contentFit={contentFit}
@@ -205,6 +241,24 @@ export const MediaSurface = memo(function MediaSurface({
205
241
 
206
242
  MediaSurface.displayName = 'MediaSurface';
207
243
 
244
+ let hasWarnedAboutSlotChurn = false;
245
+
246
+ /**
247
+ * One warning per module lifetime, and none in production — same mechanism as
248
+ * `warnExpoVideoUnavailable`.
249
+ */
250
+ function warnSlotNotMemoised(): void {
251
+ if (process.env.NODE_ENV === 'production' || hasWarnedAboutSlotChurn) return;
252
+ hasWarnedAboutSlotChurn = true;
253
+ // eslint-disable-next-line no-console
254
+ 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.',
259
+ );
260
+ }
261
+
208
262
  /**
209
263
  * The STILL of a media item — a video's poster, or the image itself — and never
210
264
  * a video view.
@@ -30,3 +30,25 @@ export const SURFACE_MOUNT_TIMEOUT_MS = 250;
30
30
  * `flyBack(id)` and `releaseFlight(id)` are the escape hatches, and they are
31
31
  * the caller's to use.
32
32
  */
33
+
34
+ /**
35
+ * How many times a video slot may change identity before Bloom says so.
36
+ *
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.
42
+ */
43
+ export const SLOT_IDENTITY_CHURN_LIMIT = 5;
44
+
45
+ /**
46
+ * How far a media element may sit from the box it is supposed to fill, in px,
47
+ * before Bloom says so.
48
+ *
49
+ * Two, not zero: subpixel layout and a box mid-animation both land within a
50
+ * pixel, and the defect this catches is not subtle — a `<video>` that never
51
+ * received Bloom's sizing style paints at its intrinsic 300x150 inside whatever
52
+ * box it was given.
53
+ */
54
+ export const SLOT_FILL_TOLERANCE_PX = 2;
@@ -60,12 +60,14 @@
60
60
  * DETACHED would pause.
61
61
  */
62
62
  import { hasFlight } from './store';
63
- import type { MediaSurfaceContent } from './types';
63
+ import type { MediaSurfaceContent, MediaVideoSlot } from './types';
64
64
 
65
65
  /** What the layer paints into a shared node, as its current holder wants it. */
66
66
  export interface MediaNodeRender {
67
67
  content: MediaSurfaceContent;
68
68
  contentFit: 'contain' | 'cover';
69
+ /** The consumer's own video element, if it brought one. Compared by identity. */
70
+ renderVideo: MediaVideoSlot | undefined;
69
71
  nativeControls: boolean;
70
72
  accessibilityLabel: string | undefined;
71
73
  /** Forwarded to the media, so a consumer keeps its own first-frame signal. */
@@ -81,7 +83,19 @@ interface MediaNodeClaim {
81
83
  rank: number;
82
84
  /** Registration order, so the most recent host wins among equals. */
83
85
  seq: number;
84
- render: MediaNodeRender;
86
+ /**
87
+ * What this claimant wants painted — or `undefined` for a claim that decides
88
+ * only WHERE the node lives.
89
+ *
90
+ * The flight is the second kind, and that distinction is load-bearing. A
91
+ * flight that published a render of its own would replace whatever the hosts
92
+ * were painting for the length of the leg, and if a consumer brought its own
93
+ * element (`renderVideo`) the flight would swap it out and back — two
94
+ * remounts, in the middle of the one operation whose entire purpose is that
95
+ * the element is never rebuilt. Measured: the consumer's `<video>` ended the
96
+ * flight as a different node, with the original left disconnected.
97
+ */
98
+ render: MediaNodeRender | undefined;
85
99
  }
86
100
 
87
101
  interface MediaNodeRecord {
@@ -168,20 +182,42 @@ function makeWrapper(): HTMLDivElement {
168
182
  return wrapper;
169
183
  }
170
184
 
171
- function recordFor(id: string, render: MediaNodeRender): MediaNodeRecord {
185
+ function recordFor(id: string, render: MediaNodeRender | undefined): MediaNodeRecord {
172
186
  const reg = registry();
173
187
  let record = reg.nodes.get(id);
174
188
  if (!record) {
175
- record = { wrapper: makeWrapper(), claims: [], render };
189
+ // A position-only claim can be the first one in: a flight can take off
190
+ // before the host it flies from has committed. `EMPTY_RENDER` paints
191
+ // nothing until a host says what, which is the honest state.
192
+ record = { wrapper: makeWrapper(), claims: [], render: render ?? EMPTY_RENDER };
176
193
  reg.nodes.set(id, record);
177
194
  }
178
195
  return record;
179
196
  }
180
197
 
198
+ /** Nothing to paint yet. Replaced by the first host claim that arrives. */
199
+ const EMPTY_RENDER: MediaNodeRender = {
200
+ content: { uri: '' },
201
+ contentFit: 'cover',
202
+ renderVideo: undefined,
203
+ nativeControls: false,
204
+ accessibilityLabel: undefined,
205
+ flightId: undefined,
206
+ };
207
+
181
208
  /** The claim that currently holds the node: highest rank, then most recent. */
182
209
  function topClaim(record: MediaNodeRecord): MediaNodeClaim | null {
210
+ return pick(record.claims);
211
+ }
212
+
213
+ /** The highest-ranked claim that says what to PAINT. See `MediaNodeClaim.render`. */
214
+ function topRender(record: MediaNodeRecord): MediaNodeClaim | null {
215
+ return pick(record.claims.filter((claim) => claim.render !== undefined));
216
+ }
217
+
218
+ function pick(claims: readonly MediaNodeClaim[]): MediaNodeClaim | null {
183
219
  let best: MediaNodeClaim | null = null;
184
- for (const claim of record.claims) {
220
+ for (const claim of claims) {
185
221
  if (best === null || claim.rank > best.rank || (claim.rank === best.rank && claim.seq > best.seq)) {
186
222
  best = claim;
187
223
  }
@@ -206,6 +242,7 @@ function place(record: MediaNodeRecord): void {
206
242
 
207
243
  function sameRender(a: MediaNodeRender, b: MediaNodeRender): boolean {
208
244
  if (a.contentFit !== b.contentFit) return false;
245
+ if (a.renderVideo !== b.renderVideo) return false;
209
246
  if (a.nativeControls !== b.nativeControls) return false;
210
247
  if (a.accessibilityLabel !== b.accessibilityLabel) return false;
211
248
  if (a.flightId !== b.flightId) return false;
@@ -221,7 +258,7 @@ function sameRender(a: MediaNodeRender, b: MediaNodeRender): boolean {
221
258
  function publish(reg: NodeRegistry): void {
222
259
  const next: MediaNodeView[] = [];
223
260
  for (const [id, record] of reg.nodes) {
224
- record.render = topClaim(record)?.render ?? record.render;
261
+ record.render = topRender(record)?.render ?? record.render;
225
262
  next.push({ id, wrapper: record.wrapper, render: record.render });
226
263
  }
227
264
  const previous = reg.snapshot;
@@ -256,15 +293,16 @@ export function claimMediaNode(
256
293
  id: string,
257
294
  el: HTMLElement,
258
295
  rank: number,
259
- render: MediaNodeRender,
296
+ render?: MediaNodeRender,
260
297
  ): void {
261
298
  const reg = registry();
262
299
  const record = recordFor(id, render);
263
300
  const existing = record.claims.find((claim) => claim.el === el && claim.rank === rank);
264
301
  if (existing !== undefined) {
265
- if (sameRender(existing.render, render) && record.wrapper.parentElement === topClaim(record)?.el) {
266
- return;
267
- }
302
+ const unchanged =
303
+ existing.render === render ||
304
+ (existing.render !== undefined && render !== undefined && sameRender(existing.render, render));
305
+ if (unchanged && record.wrapper.parentElement === topClaim(record)?.el) return;
268
306
  existing.render = render;
269
307
  } else {
270
308
  reg.seq += 1;
@@ -432,7 +432,10 @@ export function flyBack(id: string): void {
432
432
  *
433
433
  * DO NOT replace this with a direct `unmountVideoView` call. It looks
434
434
  * equivalent, it is the half that does not matter, and the fix would disappear
435
- * silently. `MediaFlight.test.tsx` makes that substitution its control.
435
+ * silently. `MediaFlight.test.tsx` makes that substitution its control, and
436
+ * `scripts/probe-expo-video-listener-leak.mjs` measures the upstream defect
437
+ * this steps around — run it before touching any of this. The day it prints
438
+ * LEAK GONE, this commit and the tests that pin it can go.
436
439
  *
437
440
  * The cost is one frame in which the outgoing surface shows its poster instead
438
441
  * of video. It is already on its way out and the destination is live by then —
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react';
1
2
  import type { StyleProp, ViewStyle } from 'react-native';
2
3
  import type { SharedValue } from 'react-native-reanimated';
3
4
 
@@ -223,6 +224,51 @@ export interface MediaFlightController {
223
224
  progress: SharedValue<number>;
224
225
  }
225
226
 
227
+ /**
228
+ * What Bloom hands a video slot: the three things it must decide, and nothing
229
+ * else.
230
+ *
231
+ * `style` is not advisory. A DOM `<video>` is a REPLACED element, and
232
+ * `position:absolute; inset:0` with `width/height:auto` resolves it to its
233
+ * INTRINSIC size (300x150) instead of stretching — so a slot that drops this
234
+ * style paints a 300x150 video inside whatever box it was given. Spread it.
235
+ */
236
+ export interface MediaVideoSlotProps {
237
+ /**
238
+ * The player Bloom was given — never one Bloom created.
239
+ *
240
+ * `null` means UNBIND THIS ELEMENT NOW, and it must be passed straight
241
+ * through. expo-video answers a null player by emptying the source, which
242
+ * pauses the element without an event; that silence is what stops a surface
243
+ * on its way out from pausing the one the viewer just landed on. Substituting
244
+ * anything else here — keeping the old player, unmounting instead — brings
245
+ * that defect back. See `releaseFlight`.
246
+ */
247
+ player: VideoPlayerLike | null;
248
+ /** Fills the box. Spread it — see above. */
249
+ style: StyleProp<ViewStyle>;
250
+ contentFit: 'contain' | 'cover';
251
+ }
252
+
253
+ /**
254
+ * Paint the video yourself.
255
+ *
256
+ * Bloom mounts a `VideoView` with the handful of props it knows about, which is
257
+ * enough until a consumer needs something only its OWN element can give it: a
258
+ * `ref` for `startPictureInPicture()` or `enterFullscreen()`, expo-video's
259
+ * picture-in-picture callbacks, `onFirstFrameRender`, anything expo-video adds
260
+ * later. Widening Bloom's props to cover those would drag expo-video's surface
261
+ * into Bloom's API, and it would be behind again on the next release.
262
+ *
263
+ * So the consumer may build the element instead, with Bloom deciding only what
264
+ * it has to: which player, and a style that fills the box.
265
+ *
266
+ * MEMOISE IT (`useCallback`). The slot is part of what a host publishes to the
267
+ * layer, compared by identity — a new function every render republishes every
268
+ * render. Correct either way, wasteful if you skip it.
269
+ */
270
+ export type MediaVideoSlot = (props: MediaVideoSlotProps) => ReactNode;
271
+
226
272
  /**
227
273
  * A place a media surface may live: the feed row it starts in, the fullscreen
228
274
  * player it lands in, and every other end of a flight.
@@ -242,6 +288,11 @@ export interface MediaFlightHostProps {
242
288
  style?: StyleProp<ViewStyle>;
243
289
  /** How the media fills the box. Defaults to `'cover'`. */
244
290
  contentFit?: 'contain' | 'cover';
291
+ /**
292
+ * Paint the video yourself, keeping your own `ref` and expo-video props. See
293
+ * {@link MediaVideoSlot}; memoise it.
294
+ */
295
+ renderVideo?: MediaVideoSlot;
245
296
  /** Whether the video arm shows expo-video's own controls. Defaults to `false`. */
246
297
  nativeControls?: boolean;
247
298
  accessibilityLabel?: string;