@honeypathkar/react-native-lq-bottom-bar 0.0.0-stage → 0.1.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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +341 -2
  3. package/android/build.gradle +78 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/honeypathkar/lqbottombar/BackdropBlur.kt +428 -0
  6. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarPackage.kt +34 -0
  7. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarView.kt +503 -0
  8. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarViewManager.kt +119 -0
  9. package/android/src/main/java/com/honeypathkar/lqbottombar/TabSelectedEvent.kt +30 -0
  10. package/ios/LQBottomBarView.swift +287 -0
  11. package/ios/LQBottomBarViewManager.m +21 -0
  12. package/ios/LQBottomBarViewManager.swift +36 -0
  13. package/lib/commonjs/NativeBottomBar.d.ts +33 -0
  14. package/lib/commonjs/NativeBottomBar.d.ts.map +1 -0
  15. package/lib/commonjs/NativeBottomBar.js +9 -0
  16. package/lib/commonjs/NativeBottomBar.js.map +1 -0
  17. package/lib/commonjs/index.d.ts +6 -0
  18. package/lib/commonjs/index.d.ts.map +1 -0
  19. package/lib/commonjs/index.js +131 -0
  20. package/lib/commonjs/index.js.map +1 -0
  21. package/lib/commonjs/rasterise.d.ts +20 -0
  22. package/lib/commonjs/rasterise.d.ts.map +1 -0
  23. package/lib/commonjs/rasterise.js +398 -0
  24. package/lib/commonjs/rasterise.js.map +1 -0
  25. package/lib/commonjs/types.d.ts +119 -0
  26. package/lib/commonjs/types.d.ts.map +1 -0
  27. package/lib/commonjs/types.js +3 -0
  28. package/lib/commonjs/types.js.map +1 -0
  29. package/package.json +65 -4
  30. package/react-native-lq-bottom-bar.podspec +27 -0
  31. package/src/NativeBottomBar.ts +43 -0
  32. package/src/index.tsx +139 -0
  33. package/src/rasterise.tsx +444 -0
  34. package/src/types.ts +136 -0
@@ -0,0 +1,43 @@
1
+ import { Platform, requireNativeComponent } from 'react-native';
2
+ import type {
3
+ HostComponent,
4
+ StyleProp,
5
+ ViewProps,
6
+ ViewStyle,
7
+ } from 'react-native';
8
+
9
+ /**
10
+ * The native view, on whichever platform this is.
11
+ *
12
+ * Both platforms register the same component name, and both are legacy view
13
+ * managers bridged through React Native's interop layer — so there is one
14
+ * `requireNativeComponent` call and no per-platform file.
15
+ *
16
+ * `null` on anything else rather than absent: `requireNativeComponent` throws
17
+ * at REQUIRE time where the view is not registered, so it cannot sit at module
18
+ * scope unguarded on, say, web.
19
+ */
20
+
21
+ /** The shape the native side is declared with, not the public API. */
22
+ export interface NativeBottomBarProps {
23
+ items: unknown[];
24
+ selectedIndex: number;
25
+ selectedColor?: string;
26
+ unselectedColor?: string;
27
+ labeled: boolean;
28
+ onTabSelected?: (event: { nativeEvent: { index: number } }) => void;
29
+ style?: StyleProp<ViewStyle>;
30
+ pointerEvents?: ViewProps['pointerEvents'];
31
+
32
+ /** Android only; the iOS bar gets its material from UIKit. */
33
+ iconSize?: number;
34
+ blurAmount?: number;
35
+ scrimColor?: string;
36
+ }
37
+
38
+ const SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android';
39
+
40
+ export const NativeBottomBar: HostComponent<NativeBottomBarProps> | null =
41
+ SUPPORTED
42
+ ? requireNativeComponent<NativeBottomBarProps>('LQBottomBarView')
43
+ : null;
package/src/index.tsx ADDED
@@ -0,0 +1,139 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Image, Platform, StyleSheet } from 'react-native';
3
+
4
+ import { NativeBottomBar } from './NativeBottomBar';
5
+ import { useRasterisedIcons } from './rasterise';
6
+ import type {
7
+ LQBottomBarItem,
8
+ LQBottomBarProps,
9
+ LQIcon,
10
+ LQIconState,
11
+ } from './types';
12
+
13
+ export type { LQBottomBarItem, LQBottomBarProps, LQIcon, LQIconState };
14
+
15
+ /**
16
+ * THE SYSTEM TAB BAR, HOSTED ANYWHERE.
17
+ *
18
+ * A native tab bar normally only exists inside a `UITabBarController`, which
19
+ * only draws its bar for its own children — so a screen pushed above a tab
20
+ * navigator cannot have one. That is true of the CONTROLLER, not the BAR:
21
+ * `UITabBar` is a plain `UIView`, and hosting one directly gets the real
22
+ * thing rather than a drawing of it. Liquid glass on iOS 26, UIKit's own
23
+ * selection animation, and the platform's accessibility, on any screen.
24
+ *
25
+ * Android has no system bar to borrow, so it draws a matching one: same
26
+ * dimensions, same tint crossfade and icon bounce, with a blurred backdrop
27
+ * standing in for the iOS material.
28
+ *
29
+ * BOTH PLATFORMS DRAW THEIR OWN ITEMS, which is why `src/rasterise.tsx`
30
+ * exists: a view that renders its items natively cannot be handed a React
31
+ * element, so a lucide or SVG icon is captured to a PNG once and the capture
32
+ * is what crosses the bridge. See that file for the trade this makes and what
33
+ * it costs.
34
+ */
35
+
36
+ /**
37
+ * ASSETS ARE RESOLVED HERE, and they have to be.
38
+ *
39
+ * `require('./home.png')` evaluates to a NUMBER — an id into React Native's
40
+ * asset registry. The bridge converts that number into `{ uri, scale, ... }`
41
+ * only for props it knows are image sources, which it learns from the prop's
42
+ * declared type. `items` is declared as an array, so the bridge has no reason
43
+ * to look inside it: the number crosses to native as a number, the native
44
+ * loader finds nothing it recognises, and the bar draws labels with no
45
+ * glyphs. `Image.resolveAssetSource` does the same conversion by hand, which
46
+ * is what lets a caller keep writing `require(...)`.
47
+ */
48
+ const resolveIcon = (icon: LQIcon): unknown => {
49
+ if (typeof icon === 'number') return Image.resolveAssetSource(icon);
50
+ return icon;
51
+ };
52
+
53
+ /**
54
+ * `overflow: 'hidden'` on Android, always.
55
+ *
56
+ * The blurred backdrop is a real child view, so without clipping it squares
57
+ * off whatever `borderRadius` the caller gave the bar — the corners stay
58
+ * rounded on the bar's own background while the blur fills them in. iOS needs
59
+ * none of this: `UITabBar` shapes its own material.
60
+ */
61
+ const androidClip = StyleSheet.create({
62
+ bar: { overflow: 'hidden' },
63
+ });
64
+
65
+ const LQBottomBar = ({
66
+ items,
67
+ selectedIndex,
68
+ selectedColor,
69
+ unselectedColor,
70
+ labeled = true,
71
+ iconSize = 24,
72
+ onTabSelected,
73
+ style,
74
+ pointerEvents,
75
+ blurAmount = 20,
76
+ androidBackgroundColor = 'rgba(20, 20, 20, 0.45)',
77
+ }: LQBottomBarProps) => {
78
+ const { sources, host } = useRasterisedIcons(items, iconSize);
79
+
80
+ const nativeItems = useMemo(
81
+ () =>
82
+ items.map((item, index) => {
83
+ const rasterised = sources[index];
84
+ return {
85
+ title: item.title,
86
+ /*
87
+ A captured glyph wins; otherwise the icon passes through as the
88
+ image or symbol descriptor it already was — which only iOS can
89
+ resolve. Until a capture lands this is `undefined`, which draws the
90
+ item with its title and no glyph for a frame; see the cost note in
91
+ `rasterise.tsx`.
92
+ */
93
+ icon: rasterised ? { uri: rasterised } : resolveIcon(item.icon),
94
+ // Default true: one artwork, tinted by the platform for both states.
95
+ tintable: item.tintable !== false,
96
+ accessibilityLabel: item.accessibilityLabel ?? item.title,
97
+ };
98
+ }),
99
+ [items, sources],
100
+ );
101
+
102
+ if (!NativeBottomBar) return null;
103
+
104
+ const platformProps =
105
+ Platform.OS === 'android'
106
+ ? {
107
+ iconSize,
108
+ blurAmount,
109
+ scrimColor: androidBackgroundColor,
110
+ }
111
+ : // Passing Android-only props to the iOS manager would log an unknown
112
+ // prop warning for each one on every render.
113
+ null;
114
+
115
+ return (
116
+ <>
117
+ {host}
118
+ <NativeBottomBar
119
+ style={Platform.OS === 'android' ? [style, androidClip.bar] : style}
120
+ pointerEvents={pointerEvents}
121
+ items={nativeItems}
122
+ selectedIndex={selectedIndex}
123
+ selectedColor={selectedColor}
124
+ unselectedColor={unselectedColor}
125
+ labeled={labeled !== false}
126
+ {...platformProps}
127
+ /*
128
+ A REPORT, NOT A COMMIT. Both bars put their highlight back where
129
+ `selectedIndex` says it is, so a tap the caller declines to act on —
130
+ one that opens a detail screen instead of switching tabs — cannot
131
+ leave the bar showing the wrong item.
132
+ */
133
+ onTabSelected={event => onTabSelected?.(event.nativeEvent.index)}
134
+ />
135
+ </>
136
+ );
137
+ };
138
+
139
+ export default LQBottomBar;
@@ -0,0 +1,444 @@
1
+ import React, {
2
+ cloneElement,
3
+ isValidElement,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { PixelRatio, Platform, StyleSheet, View } from 'react-native';
11
+ import type { ReactElement } from 'react';
12
+
13
+ import type { LQBottomBarItem, LQIcon } from './types';
14
+
15
+ /**
16
+ * TURNING A REACT ICON INTO A UIImage.
17
+ *
18
+ * `UITabBar` renders its items natively and accepts only `UIImage`. That is
19
+ * not a limitation to argue with — it is the reason the bar looks and behaves
20
+ * like the system's own, since UIKit is drawing the items rather than
21
+ * compositing someone else's views over them.
22
+ *
23
+ * So the icon has to become an image BEFORE it reaches the bar. The way out
24
+ * is react-native-svg's `toDataURL()`: mount the icon off-screen once, ask the
25
+ * native SVG view to render itself into a PNG, hand the base64 to the bar,
26
+ * and remember the result. A lucide icon is a react-native-svg `<Svg>` with a
27
+ * forwarded ref, so one path covers lucide and hand-written SVG alike.
28
+ *
29
+ * THE ALTERNATIVE, for the record: overlay JS icons on top of the native bar.
30
+ * That accepts any renderer and needs none of this file — but it forfeits
31
+ * UIKit's selection animation, which is the thing the native bar is for. A
32
+ * one-off rasterisation is the cheaper trade.
33
+ *
34
+ * WHAT THIS COSTS. The first frame for an uncached icon has a title and no
35
+ * glyph. The cache is module-level, so it is one frame per distinct icon per
36
+ * app launch, not per mount — and a static `items` array, which is the normal
37
+ * case, pays it once at startup.
38
+ */
39
+
40
+ /**
41
+ * Captured PNGs, keyed by icon identity. Module scope: see above.
42
+ *
43
+ * SUCCESSES ONLY. An earlier version also stored failures here, as an empty
44
+ * string, to stop an uncapturable icon being retried on every render. That
45
+ * was a trap: this map outlives every mount AND survives Fast Refresh, so a
46
+ * failure recorded once was permanent for the life of the JS context. Fixing
47
+ * the cause — an icon component that did not forward its ref — then changed
48
+ * nothing, because the poisoned entry was still there and the icon was never
49
+ * attempted again. Failures are tracked per mount instead; see `failed`.
50
+ */
51
+ const cache = new Map<string, string>();
52
+
53
+ /**
54
+ * Stable ids for component types, so a cache key can name "this icon
55
+ * component" without stringifying a function.
56
+ *
57
+ * Weak, because the key must not be the thing that keeps a lazily-imported
58
+ * icon module alive.
59
+ */
60
+ const typeIds = new WeakMap<object, string>();
61
+ let nextTypeId = 0;
62
+
63
+ const typeId = (type: unknown): string => {
64
+ if (typeof type === 'string') return type;
65
+ if (typeof type !== 'function' && typeof type !== 'object') return 'anon';
66
+ if (type === null) return 'anon';
67
+ const existing = typeIds.get(type as object);
68
+ if (existing) return existing;
69
+ const id = `t${nextTypeId++}`;
70
+ typeIds.set(type as object, id);
71
+ return id;
72
+ };
73
+
74
+ /**
75
+ * OPAQUE BLACK, and the colour genuinely does not matter.
76
+ *
77
+ * The capture is handed to UIKit as a TEMPLATE image: the alpha channel is
78
+ * the shape and the system chooses the colour. What does matter is that the
79
+ * colour is RESOLVABLE — lucide defaults `color` to the literal string
80
+ * `"currentColor"`, which resolves against the `<Svg>`'s own `color` prop, and
81
+ * with nothing to resolve against the glyph rasterises empty. Setting `color`
82
+ * on the root is what gives `currentColor` a value.
83
+ */
84
+ const RASTER_COLOR = '#000000';
85
+
86
+ /**
87
+ * The dimension to ask `toDataURL` for, in whichever unit this platform means
88
+ * by it. See the long note at the call site for why they disagree.
89
+ */
90
+ const captureSize = (size: number): number =>
91
+ Platform.OS === 'android' ? Math.round(PixelRatio.get() * size) : size;
92
+
93
+ /** Props only ever serialised for the cache key, never compared deeply. */
94
+ const stableStringify = (value: unknown): string => {
95
+ if (value === null || typeof value !== 'object') {
96
+ // A function in props cannot be serialised, and two renders produce two
97
+ // identities. Collapse it to its type: a caller whose icon varies by a
98
+ // callback should pass an explicit `key`.
99
+ return typeof value === 'function' ? 'fn' : String(value);
100
+ }
101
+ if (Array.isArray(value)) {
102
+ return `[${value.map(stableStringify).join(',')}]`;
103
+ }
104
+ const record = value as Record<string, unknown>;
105
+ const keys = Object.keys(record).sort();
106
+ return `{${keys
107
+ .map(k => `${k}:${stableStringify(record[k])}`)
108
+ .join(',')}}`;
109
+ };
110
+
111
+ /** True for the two icon forms that have to be drawn before iOS can use them. */
112
+ const needsRaster = (
113
+ icon: LQIcon,
114
+ ): icon is ReactElement | ((state: never) => ReactElement) =>
115
+ typeof icon === 'function' || isValidElement(icon);
116
+
117
+ const cacheKey = (item: LQBottomBarItem, size: number): string | null => {
118
+ const { icon } = item;
119
+ if (!needsRaster(icon)) return null;
120
+
121
+ const base = item.key
122
+ ? `k:${item.key}`
123
+ : typeof icon === 'function'
124
+ ? `f:${typeId(icon)}`
125
+ : `e:${typeId((icon as ReactElement).type)}:${stableStringify(
126
+ (icon as ReactElement).props,
127
+ )}`;
128
+
129
+ return `${base}|${size}`;
130
+ };
131
+
132
+ /**
133
+ * Resolve an icon to the element that should actually be drawn, sized and
134
+ * coloured for capture.
135
+ */
136
+ const rasterElement = (icon: LQIcon, size: number): ReactElement | null => {
137
+ if (typeof icon === 'function') {
138
+ /*
139
+ ALWAYS THE UNSELECTED FACE. UIKit owns the selected appearance: it tints
140
+ the one template image it was given. Capturing the selected face too
141
+ would produce a second artwork fighting the system's own treatment —
142
+ which is also why `tintable` exists for icons that should opt out.
143
+ */
144
+ return icon({ selected: false, color: RASTER_COLOR, size });
145
+ }
146
+ if (!isValidElement(icon)) return null;
147
+
148
+ const props = (icon.props ?? {}) as Record<string, unknown>;
149
+ return cloneElement(icon as ReactElement<Record<string, unknown>>, {
150
+ // Only filled in where the caller left them out, so an icon that sets its
151
+ // own size or colours keeps them.
152
+ size: props.size ?? size,
153
+ width: props.width ?? size,
154
+ height: props.height ?? size,
155
+ color: props.color ?? RASTER_COLOR,
156
+ });
157
+ };
158
+
159
+ /**
160
+ * How long to wait for a capture before re-issuing it, and how many times.
161
+ *
162
+ * Eight tries at 100ms is 800ms of patience before an icon is written off,
163
+ * which is generous for something that normally lands on the first frame and
164
+ * still short enough that a genuinely uncapturable icon does not hold the
165
+ * off-screen host mounted for the session.
166
+ */
167
+ const RETRY_MS = 100;
168
+ const MAX_TRIES = 8;
169
+
170
+ interface CaptureProps {
171
+ cacheKey: string;
172
+ node: ReactElement;
173
+ size: number;
174
+ onCaptured: (key: string, dataUri: string | null) => void;
175
+ }
176
+
177
+ /**
178
+ * One off-screen icon, captured once.
179
+ *
180
+ * `toDataURL` needs the NATIVE view to exist and, on Android, to have been
181
+ * drawn at least once — neither of which is true when the effect first runs:
182
+ * React has committed the tree, but the native view is mounted and laid out a
183
+ * frame later. Hence the frame of delay.
184
+ *
185
+ * THE WATCHDOG IS NOT BELT-AND-BRACES, it is the only thing covering the worst
186
+ * failure mode. On Android `SvgViewModule.toDataURL` checks `notRendered()`
187
+ * and, if the view has not drawn yet, parks the request as a task to be run
188
+ * from the next `onDraw` — and returns WITHOUT invoking the callback. So a
189
+ * retry driven from inside the callback cannot fire: nothing ever calls it.
190
+ * Re-issuing the whole request on a timer is what breaks that deadlock, and
191
+ * the attempt cap is what stops it running forever if the icon simply cannot
192
+ * be drawn.
193
+ */
194
+ const Capture = ({ cacheKey: key, node, size, onCaptured }: CaptureProps) => {
195
+ const ref = useRef<{ toDataURL?: Function } | null>(null);
196
+
197
+ /**
198
+ * Survives effect re-runs on purpose.
199
+ *
200
+ * `cancelled` below is per-run, so a re-run restarts a capture that has not
201
+ * landed. This one is per-COMPONENT, so a capture that already reported is
202
+ * never reported twice — which it would be if both flags were the same ref,
203
+ * and which the earlier version of this file got wrong in the other
204
+ * direction: a cleanup that set the shared flag killed every subsequent
205
+ * attempt, so any re-render before the first capture landed meant no glyph
206
+ * ever appeared.
207
+ */
208
+ const reported = useRef(false);
209
+
210
+ useEffect(() => {
211
+ let cancelled = false;
212
+ let tries = 0;
213
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
214
+ let frame = 0;
215
+
216
+ const report = (dataUri: string | null) => {
217
+ if (cancelled || reported.current) return;
218
+ reported.current = true;
219
+ if (watchdog) clearTimeout(watchdog);
220
+ onCaptured(key, dataUri);
221
+ };
222
+
223
+ const issue = () => {
224
+ if (cancelled || reported.current) return;
225
+
226
+ if (tries >= MAX_TRIES) {
227
+ report(null);
228
+ return;
229
+ }
230
+ tries += 1;
231
+
232
+ const svg = ref.current;
233
+ if (!svg || typeof svg.toDataURL !== 'function') {
234
+ /*
235
+ Either the native view is not up yet, or this is not an <Svg> root at
236
+ all — the usual cause of the latter being an icon wrapped in a <View>,
237
+ where the ref lands on the wrapper and the SVG underneath is
238
+ unreachable. Both are retried, because the first resolves itself and
239
+ the second costs only the attempt budget before falling through to a
240
+ reported miss.
241
+ */
242
+ watchdog = setTimeout(issue, RETRY_MS);
243
+ return;
244
+ }
245
+
246
+ watchdog = setTimeout(issue, RETRY_MS);
247
+
248
+ svg.toDataURL(
249
+ (base64: string) => {
250
+ if (base64) {
251
+ /*
252
+ `toDataURL` yields BARE base64, not a data URI — the name is a
253
+ long-standing misnomer in react-native-svg. The prefix is added
254
+ here so the native side receives something it recognises as an
255
+ image source.
256
+ */
257
+ report(`data:image/png;base64,${base64}`);
258
+ }
259
+ // An empty result is left to the watchdog rather than retried here,
260
+ // so there is one retry path instead of two that can race.
261
+ },
262
+ /*
263
+ THE UNITS DIFFER BY PLATFORM, and getting it wrong fails quietly in
264
+ two different ways. Hence `captureSize`.
265
+
266
+ iOS takes POINTS. The renderer is a `UIGraphicsImageRenderer` at the
267
+ screen's scale, so a 24-point request yields a 24x24pt / 72x72px PNG
268
+ on a 3x device; PNG carries no scale, so the native side decodes it
269
+ with `UIScreen.main.scale` and the two cancel out. Asking for
270
+ `size * pixelRatio` here would double-apply the scale and draw the
271
+ glyph three times too large.
272
+
273
+ Android takes PIXELS. `SvgView.toDataURL(w, h)` allocates a bitmap of
274
+ exactly w by h pixels and then draws the SVG in the VIEW's own
275
+ coordinate space, which is already in pixels — so a 24-pixel bitmap
276
+ on a 3x device captures the top-left ninth of a 72-pixel glyph and
277
+ discards the rest. The bitmap has to be the full pixel size, and the
278
+ native side scales it back down to `size` dp when it draws.
279
+ */
280
+ { width: captureSize(size), height: captureSize(size) },
281
+ );
282
+ };
283
+
284
+ frame = requestAnimationFrame(issue);
285
+ return () => {
286
+ cancelled = true;
287
+ if (watchdog) clearTimeout(watchdog);
288
+ cancelAnimationFrame(frame);
289
+ };
290
+ }, [key, size, onCaptured]);
291
+
292
+ return cloneElement(node as ReactElement<{ ref?: unknown }>, { ref });
293
+ };
294
+
295
+ const styles = StyleSheet.create({
296
+ /**
297
+ * OFF-SCREEN, not invisible, and NOT CLIPPED.
298
+ *
299
+ * `opacity: 0` and `display: 'none'` are both tempting and both wrong: the
300
+ * first relies on the renderer still drawing a fully transparent subtree,
301
+ * and the second detaches the native view so there is nothing left to
302
+ * capture. Moving it out of the viewport keeps a real, laid-out native view
303
+ * that the user cannot see.
304
+ *
305
+ * No size and no `overflow: 'hidden'` either, which an earlier version had.
306
+ * Pinning the host to 1x1 and clipping is the obvious way to keep it out of
307
+ * the way, but on Android a capture is only possible once the SVG has been
308
+ * drawn at least once — so clipping the icons out of the host is clipping
309
+ * away the very thing being waited on. Without a size the host sizes itself
310
+ * to its content, and being off-screen is enough on its own.
311
+ */
312
+ host: {
313
+ position: 'absolute',
314
+ left: -10000,
315
+ top: -10000,
316
+ },
317
+ });
318
+
319
+ export interface RasterisedIcons {
320
+ /**
321
+ * Per item: the captured data URI, or `null` when the icon needs no
322
+ * rasterising (an image or SF Symbol) or has not been captured yet.
323
+ */
324
+ sources: (string | null)[];
325
+ /** Mount this somewhere in the tree. Renders nothing visible. */
326
+ host: ReactElement | null;
327
+ }
328
+
329
+ /**
330
+ * Capture every React-element icon in `items`, memoised by icon identity.
331
+ *
332
+ * Returns the captured sources plus the off-screen host that does the
333
+ * capturing — the caller has to render the host, since a hook cannot mount
334
+ * views on its own.
335
+ */
336
+ export const useRasterisedIcons = (
337
+ items: LQBottomBarItem[],
338
+ size: number,
339
+ ): RasterisedIcons => {
340
+ const keys = useMemo(
341
+ () => items.map(item => cacheKey(item, size)),
342
+ [items, size],
343
+ );
344
+
345
+ /**
346
+ * Captures that landed during THIS mount.
347
+ *
348
+ * The module cache is the source of truth and survives unmounting, but a
349
+ * write to it is invisible to the reconciler. This mirror is what actually
350
+ * triggers the re-render; it is only ever added to, and it is seeded from
351
+ * nothing because a warm cache needs no re-render — `sources` reads it
352
+ * directly on the first pass.
353
+ */
354
+ const [captured, setCaptured] = useState<Record<string, string>>({});
355
+
356
+ /**
357
+ * Icons that could not be captured DURING THIS MOUNT.
358
+ *
359
+ * Held in a ref rather than in the module cache so the scope is right in
360
+ * both directions: within a mount an icon is attempted once, so a glyph that
361
+ * genuinely cannot be drawn does not spin; across mounts the slate is clean,
362
+ * so remounting the bar — or a Fast Refresh that fixed the icon — gets
363
+ * another go. A permanent record of failure is what made a fixed icon stay
364
+ * broken until the app was killed.
365
+ */
366
+ const failed = useRef<Set<string>>(new Set());
367
+
368
+ const onCaptured = useCallback((key: string, dataUri: string | null) => {
369
+ if (dataUri) {
370
+ cache.set(key, dataUri);
371
+ } else {
372
+ failed.current.add(key);
373
+ if (__DEV__) {
374
+ /*
375
+ LOUD, because the failure is otherwise invisible: the item renders
376
+ with its title and no glyph, which reads as a styling problem rather
377
+ than a capture that never happened.
378
+ */
379
+ console.warn(
380
+ `[lq-bottom-bar] Could not rasterise the icon for "${key}". ` +
381
+ 'The icon must be a react-native-svg <Svg> element, or a component ' +
382
+ 'that forwards its ref to one — a plain function component drops ' +
383
+ 'the ref and cannot be captured. Wrap it in React.forwardRef.',
384
+ );
385
+ }
386
+ }
387
+ setCaptured(prev => ({ ...prev, [key]: dataUri ?? '' }));
388
+ }, []);
389
+
390
+ const sources = useMemo(
391
+ () =>
392
+ keys.map(key => {
393
+ if (!key) return null;
394
+ return cache.get(key) ?? null;
395
+ }),
396
+ // `captured` is what makes this re-run; the cache it reads is module state.
397
+ // eslint-disable-next-line react-hooks/exhaustive-deps
398
+ [keys, captured],
399
+ );
400
+
401
+ const pending = useMemo(
402
+ () =>
403
+ keys
404
+ .map((key, index) => ({ key, index }))
405
+ .filter(
406
+ (entry): entry is { key: string; index: number } =>
407
+ entry.key !== null &&
408
+ !cache.has(entry.key) &&
409
+ !failed.current.has(entry.key),
410
+ ),
411
+ // eslint-disable-next-line react-hooks/exhaustive-deps
412
+ [keys, captured],
413
+ );
414
+
415
+ const host =
416
+ pending.length === 0 ? null : (
417
+ <View style={styles.host} pointerEvents="none" collapsable={false}>
418
+ {pending.map(({ key, index }) => {
419
+ const node = rasterElement(items[index].icon, size);
420
+ if (!node) return null;
421
+ return (
422
+ /*
423
+ `onCaptured` IS PASSED AS-IS, not wrapped in an arrow.
424
+
425
+ An inline `dataUri => onCaptured(key, dataUri)` is a new function
426
+ identity on every render, which lands in `Capture`'s effect deps
427
+ and re-runs the effect — tearing down an in-flight capture on
428
+ every parent render. The key travels as a prop instead so the
429
+ callback can stay stable.
430
+ */
431
+ <Capture
432
+ key={key}
433
+ cacheKey={key}
434
+ node={node}
435
+ size={size}
436
+ onCaptured={onCaptured}
437
+ />
438
+ );
439
+ })}
440
+ </View>
441
+ );
442
+
443
+ return { sources, host };
444
+ };