stream-chat-react-native-core 9.7.0 → 9.7.1-beta.2

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 (43) hide show
  1. package/lib/commonjs/components/Message/utils/measureInWindow.js +51 -8
  2. package/lib/commonjs/components/Message/utils/measureInWindow.js.map +1 -1
  3. package/lib/commonjs/components/UIComponents/PortalWhileClosingView.js +6 -13
  4. package/lib/commonjs/components/UIComponents/PortalWhileClosingView.js.map +1 -1
  5. package/lib/commonjs/mock-builders/DB/mock.js +2 -0
  6. package/lib/commonjs/mock-builders/DB/mock.js.map +1 -1
  7. package/lib/commonjs/utils/isRNVersionGreaterThanOrEqualTo.js +22 -0
  8. package/lib/commonjs/utils/isRNVersionGreaterThanOrEqualTo.js.map +1 -0
  9. package/lib/commonjs/utils/react-native-constants.js +7 -0
  10. package/lib/commonjs/utils/react-native-constants.js.map +1 -0
  11. package/lib/commonjs/version.json +1 -1
  12. package/lib/module/components/Message/utils/measureInWindow.js +51 -8
  13. package/lib/module/components/Message/utils/measureInWindow.js.map +1 -1
  14. package/lib/module/components/UIComponents/PortalWhileClosingView.js +6 -13
  15. package/lib/module/components/UIComponents/PortalWhileClosingView.js.map +1 -1
  16. package/lib/module/mock-builders/DB/mock.js +2 -0
  17. package/lib/module/mock-builders/DB/mock.js.map +1 -1
  18. package/lib/module/utils/isRNVersionGreaterThanOrEqualTo.js +22 -0
  19. package/lib/module/utils/isRNVersionGreaterThanOrEqualTo.js.map +1 -0
  20. package/lib/module/utils/react-native-constants.js +7 -0
  21. package/lib/module/utils/react-native-constants.js.map +1 -0
  22. package/lib/module/version.json +1 -1
  23. package/lib/typescript/components/Message/utils/measureInWindow.d.ts +12 -2
  24. package/lib/typescript/components/Message/utils/measureInWindow.d.ts.map +1 -1
  25. package/lib/typescript/components/UIComponents/PortalWhileClosingView.d.ts.map +1 -1
  26. package/lib/typescript/utils/isRNVersionGreaterThanOrEqualTo.d.ts +9 -0
  27. package/lib/typescript/utils/isRNVersionGreaterThanOrEqualTo.d.ts.map +1 -0
  28. package/lib/typescript/utils/react-native-constants.d.ts +8 -0
  29. package/lib/typescript/utils/react-native-constants.d.ts.map +1 -0
  30. package/package.json +10 -10
  31. package/src/__tests__/offline-support/offline-feature.tsx +43 -34
  32. package/src/components/Message/utils/__tests__/measureInWindow.test.ts +217 -0
  33. package/src/components/Message/utils/measureInWindow.ts +69 -6
  34. package/src/components/MessageInput/__tests__/__snapshots__/AttachButton.test.tsx.snap +15 -0
  35. package/src/components/MessageInput/__tests__/__snapshots__/SendButton.test.tsx.snap +10 -0
  36. package/src/components/MessageList/__tests__/__snapshots__/ScrollToBottomButton.test.tsx.snap +1 -0
  37. package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +6 -0
  38. package/src/components/UIComponents/PortalWhileClosingView.tsx +9 -14
  39. package/src/mock-builders/DB/mock.ts +2 -0
  40. package/src/utils/__tests__/isRNVersionGreaterThanOrEqualTo.test.ts +60 -0
  41. package/src/utils/isRNVersionGreaterThanOrEqualTo.ts +27 -0
  42. package/src/utils/react-native-constants.ts +10 -0
  43. package/src/version.json +1 -1
@@ -257,8 +257,8 @@ export const Generic = () => {
257
257
  } as ChannelFilters;
258
258
  const sort: ChannelSort = { last_updated: 1 };
259
259
 
260
- const renderComponent = () =>
261
- render(
260
+ const renderComponent = async () => {
261
+ const result = render(
262
262
  <Chat client={chatClient} enableOfflineSupport>
263
263
  <WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
264
264
  <ChannelList filters={filters} sort={sort} swipeActionsEnabled={false} />
@@ -266,6 +266,15 @@ export const Generic = () => {
266
266
  </Chat>,
267
267
  );
268
268
 
269
+ await waitFor(async () => {
270
+ const tablesInDb = (await BetterSqlite.getTables()) as Array<{ name: string }>;
271
+ const tableNamesInDB = tablesInDb.map((table) => table.name);
272
+ expect(Object.keys(tables).every((name) => tableNamesInDB.includes(name))).toBe(true);
273
+ });
274
+
275
+ return result;
276
+ };
277
+
269
278
  const expectCIDsOnUIToBeInDB = async (
270
279
  queryAllByLabelText: typeof screen.queryAllByLabelText,
271
280
  ) => {
@@ -365,7 +374,7 @@ export const Generic = () => {
365
374
 
366
375
  it('should store filter-sort query and cids on ChannelList in channelQueries table', async () => {
367
376
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
368
- renderComponent();
377
+ await renderComponent();
369
378
 
370
379
  await act(() => dispatchConnectionChangedEvent(chatClient, false));
371
380
  // await waiter();
@@ -381,7 +390,7 @@ export const Generic = () => {
381
390
  it('should store channels and its state in tables', async () => {
382
391
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
383
392
 
384
- renderComponent();
393
+ await renderComponent();
385
394
 
386
395
  act(() => dispatchConnectionChangedEvent(chatClient));
387
396
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
@@ -400,7 +409,7 @@ export const Generic = () => {
400
409
  useMockedApis(chatClient, [queryChannelsApi([emptyChannel])]);
401
410
  jest.spyOn(chatClient, 'hydrateActiveChannels');
402
411
 
403
- renderComponent();
412
+ await renderComponent();
404
413
 
405
414
  await waitFor(async () => {
406
415
  act(() => dispatchConnectionChangedEvent(chatClient));
@@ -415,7 +424,7 @@ export const Generic = () => {
415
424
  it('should add a new message to database', async () => {
416
425
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
417
426
 
418
- renderComponent();
427
+ await renderComponent();
419
428
  act(() => dispatchConnectionChangedEvent(chatClient));
420
429
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
421
430
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -444,7 +453,7 @@ export const Generic = () => {
444
453
  it('should correctly handle multiple new messages and add them to the database', async () => {
445
454
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
446
455
 
447
- renderComponent();
456
+ await renderComponent();
448
457
  act(() => dispatchConnectionChangedEvent(chatClient));
449
458
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
450
459
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -506,7 +515,7 @@ export const Generic = () => {
506
515
  it('should correctly handle multiple new messages from our own user', async () => {
507
516
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
508
517
 
509
- renderComponent();
518
+ await renderComponent();
510
519
  act(() => dispatchConnectionChangedEvent(chatClient));
511
520
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
512
521
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -568,7 +577,7 @@ export const Generic = () => {
568
577
  it('should add a new channel and a new message to database from notification event', async () => {
569
578
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
570
579
 
571
- renderComponent();
580
+ await renderComponent();
572
581
  act(() => dispatchConnectionChangedEvent(chatClient));
573
582
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
574
583
  await waitFor(() => {
@@ -609,7 +618,7 @@ export const Generic = () => {
609
618
  it('should update a message in database', async () => {
610
619
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
611
620
 
612
- renderComponent();
621
+ await renderComponent();
613
622
  act(() => dispatchConnectionChangedEvent(chatClient));
614
623
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
615
624
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -637,7 +646,7 @@ export const Generic = () => {
637
646
  it('should remove the channel from DB when user is removed as member', async () => {
638
647
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
639
648
 
640
- renderComponent();
649
+ await renderComponent();
641
650
  act(() => dispatchConnectionChangedEvent(chatClient));
642
651
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
643
652
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -671,7 +680,7 @@ export const Generic = () => {
671
680
  it('should remove the channel from DB if the channel is deleted', async () => {
672
681
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
673
682
 
674
- renderComponent();
683
+ await renderComponent();
675
684
  act(() => dispatchConnectionChangedEvent(chatClient));
676
685
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
677
686
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -705,7 +714,7 @@ export const Generic = () => {
705
714
  it('should correctly mark the channel as hidden in the db', async () => {
706
715
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
707
716
 
708
- renderComponent();
717
+ await renderComponent();
709
718
  act(() => dispatchConnectionChangedEvent(chatClient));
710
719
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
711
720
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -739,7 +748,7 @@ export const Generic = () => {
739
748
  it('should correctly mark the channel as visible if it was hidden before in the db', async () => {
740
749
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
741
750
 
742
- renderComponent();
751
+ await renderComponent();
743
752
  act(() => dispatchConnectionChangedEvent(chatClient));
744
753
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
745
754
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -800,7 +809,7 @@ export const Generic = () => {
800
809
  it('should add the channel to DB when user is added as member', async () => {
801
810
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
802
811
 
803
- renderComponent();
812
+ await renderComponent();
804
813
  act(() => dispatchConnectionChangedEvent(chatClient));
805
814
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
806
815
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -838,7 +847,7 @@ export const Generic = () => {
838
847
  it('should remove the channel messages from DB when channel is truncated', async () => {
839
848
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
840
849
 
841
- renderComponent();
850
+ await renderComponent();
842
851
  act(() => dispatchConnectionChangedEvent(chatClient));
843
852
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
844
853
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -874,7 +883,7 @@ export const Generic = () => {
874
883
  it('should truncate the correct messages if channel.truncated arrives with truncated_at', async () => {
875
884
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
876
885
 
877
- renderComponent();
886
+ await renderComponent();
878
887
  act(() => dispatchConnectionChangedEvent(chatClient));
879
888
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
880
889
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -926,7 +935,7 @@ export const Generic = () => {
926
935
  it('should gracefully handle a truncated_at date before each message', async () => {
927
936
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
928
937
 
929
- renderComponent();
938
+ await renderComponent();
930
939
  act(() => dispatchConnectionChangedEvent(chatClient));
931
940
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
932
941
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -962,7 +971,7 @@ export const Generic = () => {
962
971
  it('should gracefully handle a truncated_at date after each message', async () => {
963
972
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
964
973
 
965
- renderComponent();
974
+ await renderComponent();
966
975
  act(() => dispatchConnectionChangedEvent(chatClient));
967
976
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
968
977
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1004,7 +1013,7 @@ export const Generic = () => {
1004
1013
 
1005
1014
  it('should add a reaction to DB when a new reaction is added', async () => {
1006
1015
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1007
- renderComponent();
1016
+ await renderComponent();
1008
1017
  act(() => dispatchConnectionChangedEvent(chatClient));
1009
1018
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1010
1019
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1049,7 +1058,7 @@ export const Generic = () => {
1049
1058
 
1050
1059
  it('should correctly add multiple reactions to the DB', async () => {
1051
1060
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1052
- renderComponent();
1061
+ await renderComponent();
1053
1062
  act(() => dispatchConnectionChangedEvent(chatClient));
1054
1063
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1055
1064
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1136,7 +1145,7 @@ export const Generic = () => {
1136
1145
 
1137
1146
  it('should gracefully handle multiple reaction.new events of the same type for the same user', async () => {
1138
1147
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1139
- renderComponent();
1148
+ await renderComponent();
1140
1149
  act(() => dispatchConnectionChangedEvent(chatClient));
1141
1150
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1142
1151
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1205,7 +1214,7 @@ export const Generic = () => {
1205
1214
  it('should remove a reaction from DB when reaction is deleted', async () => {
1206
1215
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1207
1216
 
1208
- renderComponent();
1217
+ await renderComponent();
1209
1218
  act(() => dispatchConnectionChangedEvent(chatClient));
1210
1219
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1211
1220
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1259,7 +1268,7 @@ export const Generic = () => {
1259
1268
  it('should update a reaction in DB when reaction is updated', async () => {
1260
1269
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1261
1270
 
1262
- renderComponent();
1271
+ await renderComponent();
1263
1272
  act(() => dispatchConnectionChangedEvent(chatClient));
1264
1273
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1265
1274
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1296,7 +1305,7 @@ export const Generic = () => {
1296
1305
 
1297
1306
  it('should correctly upsert reactions when enforce_unique is true', async () => {
1298
1307
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1299
- renderComponent();
1308
+ await renderComponent();
1300
1309
  act(() => dispatchConnectionChangedEvent(chatClient));
1301
1310
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1302
1311
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1396,7 +1405,7 @@ export const Generic = () => {
1396
1405
 
1397
1406
  it('should also update the corresponding message.reaction_groups with reaction.new', async () => {
1398
1407
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1399
- renderComponent();
1408
+ await renderComponent();
1400
1409
  act(() => dispatchConnectionChangedEvent(chatClient));
1401
1410
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1402
1411
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1455,7 +1464,7 @@ export const Generic = () => {
1455
1464
 
1456
1465
  it('should also update the corresponding message.reaction_groups with reaction.updated', async () => {
1457
1466
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1458
- renderComponent();
1467
+ await renderComponent();
1459
1468
  act(() => dispatchConnectionChangedEvent(chatClient));
1460
1469
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1461
1470
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1512,7 +1521,7 @@ export const Generic = () => {
1512
1521
 
1513
1522
  it('should also update the corresponding message.reaction_groups with reaction.deleted', async () => {
1514
1523
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1515
- renderComponent();
1524
+ await renderComponent();
1516
1525
  act(() => dispatchConnectionChangedEvent(chatClient));
1517
1526
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1518
1527
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1569,7 +1578,7 @@ export const Generic = () => {
1569
1578
 
1570
1579
  it('should add a member to DB when a new member is added to channel', async () => {
1571
1580
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1572
- renderComponent();
1581
+ await renderComponent();
1573
1582
 
1574
1583
  act(() => dispatchConnectionChangedEvent(chatClient));
1575
1584
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
@@ -1592,7 +1601,7 @@ export const Generic = () => {
1592
1601
  it('should remove a member from DB when a member is removed from channel', async () => {
1593
1602
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1594
1603
 
1595
- renderComponent();
1604
+ await renderComponent();
1596
1605
  act(() => dispatchConnectionChangedEvent(chatClient));
1597
1606
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1598
1607
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1614,7 +1623,7 @@ export const Generic = () => {
1614
1623
  it('should update the member in DB when a member of a channel is updated', async () => {
1615
1624
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1616
1625
 
1617
- renderComponent();
1626
+ await renderComponent();
1618
1627
  act(() => dispatchConnectionChangedEvent(chatClient));
1619
1628
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1620
1629
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1641,7 +1650,7 @@ export const Generic = () => {
1641
1650
  it('should update the channel data in DB when a channel is updated', async () => {
1642
1651
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1643
1652
 
1644
- renderComponent();
1653
+ await renderComponent();
1645
1654
  act(() => dispatchConnectionChangedEvent(chatClient));
1646
1655
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1647
1656
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1667,7 +1676,7 @@ export const Generic = () => {
1667
1676
  it('should update reads in DB when channel is read', async () => {
1668
1677
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1669
1678
 
1670
- renderComponent();
1679
+ await renderComponent();
1671
1680
  act(() => dispatchConnectionChangedEvent(chatClient));
1672
1681
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1673
1682
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -1715,7 +1724,7 @@ export const Generic = () => {
1715
1724
  it('should update reads in DB when a channel is marked as unread', async () => {
1716
1725
  useMockedApis(chatClient, [queryChannelsApi(channels)]);
1717
1726
 
1718
- renderComponent();
1727
+ await renderComponent();
1719
1728
  act(() => dispatchConnectionChangedEvent(chatClient));
1720
1729
  await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
1721
1730
  await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
@@ -0,0 +1,217 @@
1
+ import { Dimensions, Platform, View } from 'react-native';
2
+
3
+ import type { EdgeInsets } from 'react-native-safe-area-context';
4
+
5
+ import { isMeasuredRectBogus, measureInWindow } from '../measureInWindow';
6
+
7
+ // `measureInWindow` is globally mocked in jest-setup so other suites don't hit native
8
+ // measurement; this suite exercises the real implementation.
9
+ jest.unmock('../measureInWindow');
10
+
11
+ // measureInWindow reads `isRN86OrGreater` as a live binding at call time, so mocking the constants
12
+ // module with a mutable flag lets us exercise both the "RN handles the inset" and "we compensate"
13
+ // branches without reimporting. (Flag is `mock`-prefixed as jest requires for values referenced
14
+ // inside a mock factory.)
15
+ let mockIsRN86OrGreater = false;
16
+ jest.mock('../../../../utils/react-native-constants', () => ({
17
+ __esModule: true,
18
+ get isRN86OrGreater() {
19
+ return mockIsRN86OrGreater;
20
+ },
21
+ }));
22
+
23
+ // screen 400x800 => bogus threshold is |x| > 800 and |y| > 1600 (2x each)
24
+ const SCREEN = { fontScale: 1, height: 800, scale: 2, width: 400 };
25
+ const INSETS: EdgeInsets = { bottom: 10, left: 0, right: 0, top: 24 };
26
+
27
+ const setPlatform = (os: typeof Platform.OS) => {
28
+ Object.defineProperty(Platform, 'OS', { configurable: true, get: () => os });
29
+ };
30
+
31
+ type MeasureInWindowTuple = [number, number, number, number];
32
+ type MeasureTuple = [number, number, number, number, number, number];
33
+
34
+ /**
35
+ * Builds a fake native handle whose `measureInWindow`/`measure` callbacks fire synchronously
36
+ * (as Fabric does). Omitting `measure` produces a handle without the method, exercising the
37
+ * "no fallback available" branch.
38
+ */
39
+ const makeNode = ({
40
+ measure,
41
+ measureInWindow: miw,
42
+ }: {
43
+ measure?: MeasureTuple;
44
+ measureInWindow: MeasureInWindowTuple;
45
+ }): { current: View | null } => {
46
+ const handle: Record<string, unknown> = {
47
+ measureInWindow: (cb: (...args: MeasureInWindowTuple) => void) => cb(...miw),
48
+ };
49
+ if (measure) {
50
+ handle.measure = (cb: (...args: MeasureTuple) => void) => cb(...measure);
51
+ }
52
+ return { current: handle as unknown as View };
53
+ };
54
+
55
+ describe('isMeasuredRectBogus', () => {
56
+ beforeEach(() => {
57
+ jest.spyOn(Dimensions, 'get').mockReturnValue(SCREEN);
58
+ });
59
+
60
+ afterEach(() => {
61
+ jest.restoreAllMocks();
62
+ });
63
+
64
+ it('accepts a normal on-screen rect', () => {
65
+ expect(isMeasuredRectBogus(50, 300, 200, 40)).toBe(false);
66
+ });
67
+
68
+ it('accepts a rect within the 2x margin (e.g. partially off-screen)', () => {
69
+ expect(isMeasuredRectBogus(50, 1500, 200, 40)).toBe(false);
70
+ });
71
+
72
+ it.each([
73
+ ['NaN x', [NaN, 10, 20, 20]],
74
+ ['NaN y', [10, NaN, 20, 20]],
75
+ ['Infinity x', [Infinity, 10, 20, 20]],
76
+ ['-Infinity y', [10, -Infinity, 20, 20]],
77
+ ['NaN width', [10, 10, NaN, 20]],
78
+ ['Infinity height', [10, 10, 20, Infinity]],
79
+ ] as [string, MeasureInWindowTuple][])('flags non-finite values: %s', (_label, [x, y, w, h]) => {
80
+ expect(isMeasuredRectBogus(x, y, w, h)).toBe(true);
81
+ });
82
+
83
+ it.each([
84
+ ['zero width', [10, 10, 0, 40]],
85
+ ['zero height', [10, 10, 40, 0]],
86
+ ['negative width', [10, 10, -5, 40]],
87
+ ['negative height', [10, 10, 40, -5]],
88
+ ] as [string, MeasureInWindowTuple][])(
89
+ 'flags non-positive dimensions: %s',
90
+ (_label, [x, y, w, h]) => {
91
+ expect(isMeasuredRectBogus(x, y, w, h)).toBe(true);
92
+ },
93
+ );
94
+
95
+ it.each([
96
+ ['huge y (the FLAG_LAYOUT_NO_LIMITS failure)', [50, 29000, 40, 36]],
97
+ ['huge x', [29000, 300, 40, 36]],
98
+ ['huge negative x', [-2000, 300, 40, 36]],
99
+ ['huge negative y', [50, -2000, 40, 36]],
100
+ ] as [string, MeasureInWindowTuple][])(
101
+ 'flags coordinates beyond 2x the screen: %s',
102
+ (_label, [x, y, w, h]) => {
103
+ expect(isMeasuredRectBogus(x, y, w, h)).toBe(true);
104
+ },
105
+ );
106
+
107
+ it('treats the exact 2x boundary as valid and just past it as bogus', () => {
108
+ // bounds are 800 (x) and 1600 (y); the check uses strict `>`
109
+ expect(isMeasuredRectBogus(800, 1600, 40, 36)).toBe(false);
110
+ expect(isMeasuredRectBogus(801, 300, 40, 36)).toBe(true);
111
+ expect(isMeasuredRectBogus(50, 1601, 40, 36)).toBe(true);
112
+ });
113
+
114
+ it('trusts the measurement when screen dimensions are unavailable', () => {
115
+ jest.spyOn(Dimensions, 'get').mockReturnValue({ ...SCREEN, height: 0, width: 0 });
116
+ expect(isMeasuredRectBogus(99999, 99999, 40, 36)).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe('measureInWindow', () => {
121
+ const originalOS = Platform.OS;
122
+
123
+ beforeEach(() => {
124
+ jest.spyOn(Dimensions, 'get').mockReturnValue(SCREEN);
125
+ });
126
+
127
+ afterEach(() => {
128
+ jest.restoreAllMocks();
129
+ setPlatform(originalOS);
130
+ mockIsRN86OrGreater = false;
131
+ });
132
+
133
+ it('rejects when the node is not mounted', async () => {
134
+ await expect(measureInWindow({ current: null }, INSETS)).rejects.toThrow(/native handle/);
135
+ });
136
+
137
+ describe('healthy measurement (primary path)', () => {
138
+ it('resolves with the window rect unchanged on iOS', async () => {
139
+ setPlatform('ios');
140
+ const node = makeNode({ measureInWindow: [10, 300, 200, 40] });
141
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({
142
+ h: 40,
143
+ w: 200,
144
+ x: 10,
145
+ y: 300,
146
+ });
147
+ });
148
+
149
+ it('compensates by insets.top on Android below RN 0.86', async () => {
150
+ setPlatform('android');
151
+ mockIsRN86OrGreater = false;
152
+ const node = makeNode({ measureInWindow: [10, 300, 200, 40] });
153
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({
154
+ h: 40,
155
+ w: 200,
156
+ x: 10,
157
+ y: 324,
158
+ });
159
+ });
160
+
161
+ it('does not compensate on Android from RN 0.86 onward (RN corrects the inset natively)', async () => {
162
+ setPlatform('android');
163
+ mockIsRN86OrGreater = true;
164
+ const node = makeNode({ measureInWindow: [10, 300, 200, 40] });
165
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({
166
+ h: 40,
167
+ w: 200,
168
+ x: 10,
169
+ y: 300,
170
+ });
171
+ });
172
+ });
173
+
174
+ describe('bogus measurement (measure() fallback)', () => {
175
+ it('falls back to root-relative pageX/pageY', async () => {
176
+ setPlatform('ios');
177
+ const node = makeNode({
178
+ measure: [0, 0, 64, 36, 12, 717],
179
+ measureInWindow: [28903, 29088, 64, 36],
180
+ });
181
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({ h: 36, w: 64, x: 12, y: 717 });
182
+ });
183
+
184
+ it('does not add insets.top to the fallback, since pageY already accounts for it', async () => {
185
+ setPlatform('android');
186
+ const node = makeNode({
187
+ measure: [0, 0, 64, 36, 12, 717],
188
+ measureInWindow: [28903, 29088, 64, 36],
189
+ });
190
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({ h: 36, w: 64, x: 12, y: 717 });
191
+ });
192
+
193
+ it('returns the compensated window rect when no measure() fallback is available (RN < 0.86)', async () => {
194
+ setPlatform('android');
195
+ mockIsRN86OrGreater = false;
196
+ const node = makeNode({ measureInWindow: [28903, 29088, 64, 36] });
197
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({
198
+ h: 36,
199
+ w: 64,
200
+ x: 28903,
201
+ y: 29112,
202
+ });
203
+ });
204
+
205
+ it('returns the uncompensated window rect when no measure() fallback is available (RN >= 0.86)', async () => {
206
+ setPlatform('android');
207
+ mockIsRN86OrGreater = true;
208
+ const node = makeNode({ measureInWindow: [28903, 29088, 64, 36] });
209
+ await expect(measureInWindow(node, INSETS)).resolves.toEqual({
210
+ h: 36,
211
+ w: 64,
212
+ x: 28903,
213
+ y: 29088,
214
+ });
215
+ });
216
+ });
217
+ });
@@ -1,20 +1,83 @@
1
1
  import React from 'react';
2
- import { Platform, View } from 'react-native';
2
+ import { Dimensions, Platform, View } from 'react-native';
3
3
  import { EdgeInsets } from 'react-native-safe-area-context';
4
4
 
5
+ import { isRN86OrGreater } from '../../../utils/react-native-constants';
6
+
7
+ type MeasuredRect = { x: number; y: number; w: number; h: number };
8
+
9
+ /**
10
+ * On Android, `measureInWindow` historically did not account for the top inset, so we added it
11
+ * ourselves. React Native 0.86 fixed this upstream as part of its edge-to-edge rework and now
12
+ * returns topInset corrected window coordinates. On >= 0.86 adding it again double counts the
13
+ * inset and offsets every measured view down by `insets.top`, so we only compensate on Android
14
+ * running React Native below 0.86.
15
+ */
16
+ const androidTopInsetCompensation = (insets: EdgeInsets): number =>
17
+ Platform.OS === 'android' && !isRN86OrGreater ? insets.top : 0;
18
+
19
+ /**
20
+ * How many screen lengths away from the origin a measured coordinate may fall before we treat it as
21
+ * bogus. Real targets (a view currently on screen) always measure well within one screen and the failure
22
+ * mode we guard against is off by tens of screens, so a generous multiplier cleanly separates the two
23
+ * with no risk of false positives on legitimate values.
24
+ */
25
+ const SCREEN_BOUND_MULTIPLIER = 2;
26
+
27
+ /**
28
+ * `measureInWindow` can return wildly out of bounds coordinates on Android when the native window has
29
+ * been forced edge-to-edge behind React Native's back (i.e a library setting `FLAG_LAYOUT_NO_LIMITS`,
30
+ * as `react-native-system-navigation-bar` does for a transparent nav bar) while RN's own edge-to-edge
31
+ * (`edgeToEdgeEnabled`) is off. In that state the window relative measurement is corrupted and would
32
+ * return a position of the View far offscreen.
33
+ */
34
+ export const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): boolean => {
35
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
36
+ return true;
37
+ }
38
+ if (w <= 0 || h <= 0) {
39
+ return true;
40
+ }
41
+ const { width, height } = Dimensions.get('screen');
42
+ if (width <= 0 || height <= 0) {
43
+ // can't reason about bounds, trust the measurement
44
+ return false;
45
+ }
46
+ return (
47
+ Math.abs(x) > width * SCREEN_BOUND_MULTIPLIER || Math.abs(y) > height * SCREEN_BOUND_MULTIPLIER
48
+ );
49
+ };
50
+
5
51
  export const measureInWindow = (
6
52
  node: React.RefObject<View | null>,
7
53
  insets: EdgeInsets,
8
- ): Promise<{ x: number; y: number; w: number; h: number }> => {
54
+ ): Promise<MeasuredRect> => {
9
55
  return new Promise((resolve, reject) => {
10
56
  const handle = node.current;
11
- if (!handle)
57
+ if (!handle) {
12
58
  return reject(
13
59
  new Error('The native handle could not be found while invoking measureInWindow.'),
14
60
  );
61
+ }
62
+
63
+ handle.measureInWindow((x, y, w, h) => {
64
+ if (!isMeasuredRectBogus(x, y, w, h)) {
65
+ resolve({ h, w, x, y: y + androidTopInsetCompensation(insets) });
66
+ return;
67
+ }
68
+
69
+ // If `measureInWindow` returned an out of bounds rect, fallback to `measure()`, whose
70
+ // `pageX`/`pageY` are relative to the app root and are the same coordinate space as the window.
71
+ // They will stays correct when the window frame has been mutated out from under
72
+ // React Native.
73
+ if (typeof handle.measure !== 'function') {
74
+ resolve({ h, w, x, y: y + androidTopInsetCompensation(insets) });
75
+ return;
76
+ }
15
77
 
16
- handle.measureInWindow((x, y, w, h) =>
17
- resolve({ h, w, x, y: y + (Platform.OS === 'android' ? insets.top : 0) }),
18
- );
78
+ handle.measure((_x, _y, width, height, pageX, pageY) => {
79
+ resolve({ h: height, w: width, x: pageX, y: pageY });
80
+ });
81
+ });
19
82
  });
20
83
  };