@oxyhq/bloom 0.54.1 → 0.55.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 (56) hide show
  1. package/lib/commonjs/toast/ToastRow.js +35 -10
  2. package/lib/commonjs/toast/ToastRow.js.map +1 -1
  3. package/lib/commonjs/toast/ToastSwipeHandler.js +15 -5
  4. package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
  5. package/lib/commonjs/toast/constants.js +18 -1
  6. package/lib/commonjs/toast/constants.js.map +1 -1
  7. package/lib/commonjs/toast/toast-store.js +39 -1
  8. package/lib/commonjs/toast/toast-store.js.map +1 -1
  9. package/lib/commonjs/toast/use-stack-hover.js +175 -0
  10. package/lib/commonjs/toast/use-stack-hover.js.map +1 -0
  11. package/lib/commonjs/toast/use-stack-hover.native.js +42 -0
  12. package/lib/commonjs/toast/use-stack-hover.native.js.map +1 -0
  13. package/lib/module/toast/ToastRow.js +35 -10
  14. package/lib/module/toast/ToastRow.js.map +1 -1
  15. package/lib/module/toast/ToastSwipeHandler.js +16 -4
  16. package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
  17. package/lib/module/toast/constants.js +18 -1
  18. package/lib/module/toast/constants.js.map +1 -1
  19. package/lib/module/toast/toast-store.js +39 -1
  20. package/lib/module/toast/toast-store.js.map +1 -1
  21. package/lib/module/toast/use-stack-hover.js +168 -0
  22. package/lib/module/toast/use-stack-hover.js.map +1 -0
  23. package/lib/module/toast/use-stack-hover.native.js +35 -0
  24. package/lib/module/toast/use-stack-hover.native.js.map +1 -0
  25. package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +4 -0
  27. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/toast/toast-store.d.ts +15 -0
  30. package/lib/typescript/commonjs/toast/toast-store.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts +55 -0
  32. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts +26 -0
  34. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts.map +1 -0
  35. package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
  36. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +4 -0
  37. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
  38. package/lib/typescript/module/toast/constants.d.ts.map +1 -1
  39. package/lib/typescript/module/toast/toast-store.d.ts +15 -0
  40. package/lib/typescript/module/toast/toast-store.d.ts.map +1 -1
  41. package/lib/typescript/module/toast/use-stack-hover.d.ts +55 -0
  42. package/lib/typescript/module/toast/use-stack-hover.d.ts.map +1 -0
  43. package/lib/typescript/module/toast/use-stack-hover.native.d.ts +26 -0
  44. package/lib/typescript/module/toast/use-stack-hover.native.d.ts.map +1 -0
  45. package/package.json +1 -1
  46. package/src/__tests__/Toaster.test.tsx +554 -0
  47. package/src/__tests__/toast-position-utils.test.ts +28 -0
  48. package/src/__tests__/toast-stack-hover.test.ts +92 -0
  49. package/src/__tests__/toast-store.test.ts +72 -0
  50. package/src/toast/Toast.stories.tsx +10 -0
  51. package/src/toast/ToastRow.tsx +33 -10
  52. package/src/toast/ToastSwipeHandler.tsx +14 -2
  53. package/src/toast/constants.ts +18 -1
  54. package/src/toast/toast-store.ts +39 -1
  55. package/src/toast/use-stack-hover.native.ts +34 -0
  56. package/src/toast/use-stack-hover.ts +182 -0
@@ -1,15 +1,22 @@
1
1
  import React from 'react';
2
+ import * as ReactNative from 'react-native';
2
3
  import { AppState, Text } from 'react-native';
3
4
  import { act, fireEvent, render } from '@testing-library/react-native';
4
5
 
5
6
  import { space } from '../styles/tokens';
6
7
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
8
  import {
9
+ CLOSE_BUTTON_HIT_AREA,
8
10
  ENTERING_ANIMATION_DURATION,
11
+ toastDefaults,
9
12
  TOAST_MAX_ROW_WIDTH,
10
13
  } from '../toast/constants';
11
14
  import { toast, ToastOutlet } from '../toast';
12
15
  import { toastStore } from '../toast/toast-store';
16
+ import {
17
+ HOVER_LEAVE_GRACE,
18
+ resetStackHoverForTests,
19
+ } from '../toast/use-stack-hover';
13
20
  import type { ToasterProps, ToastPosition } from '../toast/types';
14
21
 
15
22
  /**
@@ -151,6 +158,61 @@ const rowBoxOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
151
158
  hostName(node) === 'Animated.View' && node.props['aria-live'] !== undefined,
152
159
  );
153
160
 
161
+ /** Every mounted row box — one per rendered toast, whatever its geometry. */
162
+ const rowBoxesOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
163
+ UNSAFE_root.findAll(
164
+ (node) =>
165
+ hostName(node) === 'Animated.View' && node.props['aria-live'] !== undefined,
166
+ );
167
+
168
+ /**
169
+ * The shape `__mocks__/react-native-gesture-handler.ts` records its callbacks in,
170
+ * so a suite can fire the row's REAL tap with a real row-relative x rather than
171
+ * reaching inside the component for the policy.
172
+ */
173
+ type MockGesture = {
174
+ __handlers: { onEnd?: (event: { x: number; y: number }) => void };
175
+ __members: MockGesture[];
176
+ };
177
+
178
+ const isMockGesture = (value: unknown): value is MockGesture =>
179
+ typeof value === 'object' &&
180
+ value !== null &&
181
+ '__handlers' in value &&
182
+ '__members' in value;
183
+
184
+ /**
185
+ * Fires the tap gesture of one row at a ROW-RELATIVE x, in render order (so the
186
+ * last one is the front of a bottom-anchored stack — the row a real tap lands on).
187
+ * The pan gesture registers `onBegin`/`onChange`/`onFinalize` and no `onEnd`, so
188
+ * `onEnd` identifies the tap unambiguously.
189
+ */
190
+ const tapRow = (
191
+ rendered: ReturnType<typeof renderOutlet>,
192
+ { x, row = 'front' }: { x: number; row?: 'front' | number },
193
+ ) => {
194
+ const gestures = rendered.UNSAFE_root
195
+ .findAll((node) => isMockGesture(node.props.gesture))
196
+ .flatMap((node) => {
197
+ const gesture: unknown = node.props.gesture;
198
+ return isMockGesture(gesture) ? [gesture] : [];
199
+ });
200
+ const gesture = row === 'front' ? gestures[gestures.length - 1] : gestures[row];
201
+ const onEnd = gesture?.__members.find(
202
+ (member) => typeof member.__handlers.onEnd === 'function',
203
+ )?.__handlers.onEnd;
204
+ if (!onEnd) {
205
+ throw new Error(`no tap gesture found for row ${String(row)}`);
206
+ }
207
+ act(() => {
208
+ onEnd({ x, y: 20 });
209
+ });
210
+ };
211
+
212
+ /** The mocked window is 375 wide, so the row is uncapped and the strip starts here. */
213
+ const STRIP_X = 375 - CLOSE_BUTTON_HIT_AREA + 10;
214
+ const ROW_CENTRE_X = 100;
215
+
154
216
  describe('ToastOutlet', () => {
155
217
  beforeEach(() => {
156
218
  jest.useFakeTimers();
@@ -158,6 +220,9 @@ describe('ToastOutlet', () => {
158
220
  });
159
221
 
160
222
  afterEach(() => {
223
+ // Hover state is module-level (one stack per document), so it outlives the
224
+ // render tree and has to be cleared between tests.
225
+ resetStackHoverForTests();
161
226
  act(() => {
162
227
  toastStore.dismissToast(undefined);
163
228
  jest.runOnlyPendingTimers();
@@ -522,6 +587,495 @@ describe('ToastOutlet', () => {
522
587
  });
523
588
  });
524
589
 
590
+ /**
591
+ * HOVER-TO-EXPAND (web). jest resolves `use-stack-hover.ts`, the web file, so
592
+ * these run the real trigger; `use-stack-hover.native.ts` returns no handlers at
593
+ * all and is covered by `toast-stack-hover.test.ts`.
594
+ *
595
+ * The pointer geometry itself — that hover reaches a row through the portal
596
+ * root's `pointer-events: none`, and the transforms on hover-in and hover-out —
597
+ * is only observable in a browser and is measured there.
598
+ */
599
+ describe('stack hover', () => {
600
+ const mouse = { nativeEvent: { pointerType: 'mouse' } };
601
+ const touch = { nativeEvent: { pointerType: 'touch' } };
602
+
603
+ const hoverRow = (rendered: ReturnType<typeof renderOutlet>, index = 0) => {
604
+ const row = rowBoxesOf(rendered)[index];
605
+ if (!row) {
606
+ throw new Error(`no row box at index ${index}`);
607
+ }
608
+ return row;
609
+ };
610
+
611
+ it('expands the stack when a mouse enters a row', () => {
612
+ const rendered = renderOutlet({ enableStacking: true });
613
+ show(() => {
614
+ toast('first');
615
+ toast('second');
616
+ });
617
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
618
+
619
+ act(() => {
620
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
621
+ });
622
+
623
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
624
+ });
625
+
626
+ it('ignores a touch pointer, so a tap does not expand-then-collapse', () => {
627
+ const rendered = renderOutlet({ enableStacking: true });
628
+ show(() => {
629
+ toast('first');
630
+ toast('second');
631
+ });
632
+
633
+ act(() => {
634
+ fireEvent(hoverRow(rendered), 'pointerEnter', touch);
635
+ });
636
+
637
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
638
+ });
639
+
640
+ it('collapses on leave, but only after the grace period', () => {
641
+ const rendered = renderOutlet({ enableStacking: true });
642
+ show(() => {
643
+ toast('first');
644
+ toast('second');
645
+ });
646
+ act(() => {
647
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
648
+ });
649
+
650
+ act(() => {
651
+ fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
652
+ jest.advanceTimersByTime(HOVER_LEAVE_GRACE - 1);
653
+ });
654
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
655
+
656
+ act(() => {
657
+ jest.advanceTimersByTime(1);
658
+ });
659
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
660
+ });
661
+
662
+ it('does not collapse when the pointer crosses from one row to the next', () => {
663
+ const rendered = renderOutlet({ enableStacking: true });
664
+ show(() => {
665
+ toast('first');
666
+ toast('second');
667
+ });
668
+ act(() => {
669
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
670
+ });
671
+
672
+ // The gap between two expanded rows: leave one, enter the next a tick later.
673
+ act(() => {
674
+ fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
675
+ jest.advanceTimersByTime(HOVER_LEAVE_GRACE - 20);
676
+ fireEvent(hoverRow(rendered, 1), 'pointerEnter', mouse);
677
+ jest.advanceTimersByTime(HOVER_LEAVE_GRACE * 3);
678
+ });
679
+
680
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
681
+ });
682
+
683
+ it('does not let a hovered toast auto-close under the cursor', () => {
684
+ const rendered = renderOutlet({ enableStacking: true });
685
+ show(() => {
686
+ toast('first');
687
+ toast('second');
688
+ });
689
+
690
+ act(() => {
691
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
692
+ // Well past both rows' deadlines.
693
+ jest.advanceTimersByTime(
694
+ (toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
695
+ );
696
+ });
697
+
698
+ expect(rendered.queryByText('first')).toBeTruthy();
699
+ expect(rendered.queryByText('second')).toBeTruthy();
700
+
701
+ // Leaving restarts the clock, so they still go on their own.
702
+ act(() => {
703
+ fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
704
+ jest.advanceTimersByTime(
705
+ HOVER_LEAVE_GRACE + toastDefaults.duration + ENTERING_ANIMATION_DURATION,
706
+ );
707
+ });
708
+ expect(rendered.queryByText('first')).toBeNull();
709
+ expect(rendered.queryByText('second')).toBeNull();
710
+ });
711
+
712
+ it('pauses a hovered toast with stacking off too, without expanding anything', () => {
713
+ const rendered = renderOutlet({ enableStacking: false });
714
+ show(() => {
715
+ toast('first');
716
+ toast('second');
717
+ });
718
+
719
+ act(() => {
720
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
721
+ jest.advanceTimersByTime(
722
+ (toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
723
+ );
724
+ });
725
+
726
+ expect(rendered.queryByText('first')).toBeTruthy();
727
+ // Nothing to expand in a flat column — the pointer only pauses.
728
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
729
+
730
+ act(() => {
731
+ fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
732
+ jest.advanceTimersByTime(
733
+ HOVER_LEAVE_GRACE + toastDefaults.duration + ENTERING_ANIMATION_DURATION,
734
+ );
735
+ });
736
+ expect(rendered.queryByText('first')).toBeNull();
737
+ });
738
+
739
+ it('keeps a hovered stack expanded when a row is dismissed out of it', () => {
740
+ // The store auto-collapses down to a single row so it cannot hang, but doing
741
+ // that under the cursor would resume the timers hover had just paused — the
742
+ // remaining toast would then expire while the user is reading it.
743
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
744
+ show(() => {
745
+ toast('first');
746
+ toast('second');
747
+ });
748
+ act(() => {
749
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
750
+ });
751
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
752
+
753
+ // Dismiss the front row from the close strip: 2 rows -> 1.
754
+ tapRow(rendered, { x: STRIP_X });
755
+ expect(rendered.queryByText('second')).toBeNull();
756
+
757
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
758
+ act(() => {
759
+ jest.advanceTimersByTime(
760
+ (toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
761
+ );
762
+ });
763
+ expect(rendered.queryByText('first')).toBeTruthy();
764
+
765
+ // Leaving releases the hold: the survivor collapses and resumes.
766
+ act(() => {
767
+ fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
768
+ jest.advanceTimersByTime(HOVER_LEAVE_GRACE);
769
+ });
770
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
771
+ act(() => {
772
+ jest.advanceTimersByTime(
773
+ toastDefaults.duration + ENTERING_ANIMATION_DURATION,
774
+ );
775
+ });
776
+ expect(rendered.queryByText('first')).toBeNull();
777
+ });
778
+
779
+ it('leaves the expansion alone on a press while hovering, and still runs onPress', () => {
780
+ const onPress = jest.fn();
781
+ const rendered = renderOutlet({ enableStacking: true });
782
+ show(() => {
783
+ toast('first');
784
+ toast('second', { onPress });
785
+ });
786
+ act(() => {
787
+ fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
788
+ });
789
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
790
+
791
+ // A desktop click on the row: hover owns expansion, so this must not toggle
792
+ // it shut (which would resume the timers hover just paused).
793
+ tapRow(rendered, { x: ROW_CENTRE_X });
794
+
795
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
796
+ expect(onPress).toHaveBeenCalledTimes(1);
797
+ });
798
+ });
799
+
800
+ /**
801
+ * TAPS ON A STACKED ROW — INVARIANT: NOT ONE OF THEM IS INERT.
802
+ *
803
+ * Every case here fires the row's real tap callback with a real row-relative x,
804
+ * because the defect this covers lived INSIDE that callback and a test that only
805
+ * taps row centres passes against it: the close-button strip used to require
806
+ * `isExpanded` to dismiss and did nothing whatsoever otherwise, so at default
807
+ * config (`closeButton: false`, dismiss branch unreachable) the rightmost 60dp of
808
+ * every stacked row was dead. Stacking is now the default, so that was the
809
+ * default too.
810
+ *
811
+ * The strip's x is measured from the ROW's capped width, never the window's —
812
+ * `measures the strip from the row, not the window` below is the guard on that.
813
+ */
814
+ describe('stacked row taps', () => {
815
+ it('expands a collapsed stack from the close strip instead of doing nothing', () => {
816
+ const rendered = renderOutlet({ enableStacking: true });
817
+ show(() => {
818
+ toast('first');
819
+ toast('second');
820
+ });
821
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
822
+
823
+ tapRow(rendered, { x: STRIP_X });
824
+
825
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
826
+ // Nothing was dismissed: there is no ✕ to aim at at default config.
827
+ expect(rendered.queryByText('first')).toBeTruthy();
828
+ expect(rendered.queryByText('second')).toBeTruthy();
829
+ });
830
+
831
+ it('collapses an expanded stack from the close strip when there is no close button', () => {
832
+ const rendered = renderOutlet({ enableStacking: true });
833
+ show(() => {
834
+ toast('first');
835
+ toast('second');
836
+ });
837
+ tapRow(rendered, { x: ROW_CENTRE_X });
838
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
839
+
840
+ tapRow(rendered, { x: STRIP_X });
841
+
842
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
843
+ expect(rendered.queryByText('second')).toBeTruthy();
844
+ });
845
+
846
+ it('dismisses from the close strip of a COLLAPSED stack — the ✕ is visible there', () => {
847
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
848
+ show(() => {
849
+ toast('first');
850
+ toast('second');
851
+ });
852
+
853
+ tapRow(rendered, { x: STRIP_X });
854
+
855
+ // The front row goes; the stack does not expand instead.
856
+ expect(rendered.queryByText('second')).toBeNull();
857
+ expect(rendered.queryByText('first')).toBeTruthy();
858
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
859
+ });
860
+
861
+ it('dismisses from the close strip of an expanded stack', () => {
862
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
863
+ show(() => {
864
+ toast('first');
865
+ toast('second');
866
+ });
867
+ tapRow(rendered, { x: ROW_CENTRE_X });
868
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
869
+
870
+ tapRow(rendered, { x: STRIP_X });
871
+
872
+ expect(rendered.queryByText('second')).toBeNull();
873
+ expect(rendered.queryByText('first')).toBeTruthy();
874
+ });
875
+
876
+ it('never dismisses from the strip when the toast is not dismissible', () => {
877
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
878
+ show(() => {
879
+ toast('first');
880
+ toast('second', { dismissible: false });
881
+ });
882
+
883
+ tapRow(rendered, { x: STRIP_X });
884
+
885
+ // No ✕ renders on a non-dismissible row, so the strip expands instead.
886
+ expect(rendered.queryByText('second')).toBeTruthy();
887
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
888
+ });
889
+
890
+ it('treats the strip boundary itself as row, not strip', () => {
891
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
892
+ show(() => {
893
+ toast('first');
894
+ toast('second');
895
+ });
896
+
897
+ // `x > rowWidth - CLOSE_BUTTON_HIT_AREA` — the boundary pixel is outside.
898
+ tapRow(rendered, { x: 375 - CLOSE_BUTTON_HIT_AREA });
899
+
900
+ expect(rendered.queryByText('second')).toBeTruthy();
901
+ expect(toastStore.getSnapshot().isExpanded).toBe(true);
902
+ });
903
+
904
+ it('measures the strip from the row, not the window', () => {
905
+ // A 1280px viewport caps the row at TOAST_MAX_ROW_WIDTH, so the strip starts
906
+ // at 328. Measured from the WINDOW it would start at 1220 and this tap would
907
+ // land in the row body — the failure mode the row-relative math exists for.
908
+ jest
909
+ .spyOn(ReactNative, 'useWindowDimensions')
910
+ .mockReturnValue({ width: 1280, height: 900, scale: 1, fontScale: 1 });
911
+
912
+ const rendered = renderOutlet({ enableStacking: true, closeButton: true });
913
+ show(() => {
914
+ toast('first');
915
+ toast('second');
916
+ });
917
+
918
+ tapRow(rendered, { x: TOAST_MAX_ROW_WIDTH - CLOSE_BUTTON_HIT_AREA + 10 });
919
+
920
+ expect(rendered.queryByText('second')).toBeNull();
921
+ });
922
+
923
+ it('still runs the toast onPress from inside the strip', () => {
924
+ const onPress = jest.fn();
925
+ const rendered = renderOutlet({ enableStacking: true });
926
+ show(() => {
927
+ toast('first');
928
+ toast('second', { onPress });
929
+ });
930
+
931
+ tapRow(rendered, { x: STRIP_X });
932
+
933
+ expect(onPress).toHaveBeenCalledTimes(1);
934
+ });
935
+
936
+ it('leaves a lone toast unexpanded — there is no stack to open', () => {
937
+ const rendered = renderOutlet({ enableStacking: true });
938
+ show(() => toast('only'));
939
+
940
+ tapRow(rendered, { x: ROW_CENTRE_X });
941
+
942
+ expect(toastStore.getSnapshot().isExpanded).toBe(false);
943
+ expect(rendered.queryByText('only')).toBeTruthy();
944
+ });
945
+ });
946
+
947
+ /**
948
+ * The stacked presentation itself is geometry, so it is verified where geometry
949
+ * is verifiable: `toast-position-utils.test.ts` for the offsets and the `scaleX`
950
+ * depth cue, and the `Sequential*` / `Stacking` stories in a real browser for
951
+ * the composed result. What jest can pin — and what the two `describe.each`
952
+ * blocks below deliberately do NOT pin, since they run both values — is which
953
+ * one an outlet with no props gets.
954
+ *
955
+ * `useAnimatedTarget` drives the stack transform from a layout effect, so the
956
+ * rendered style still carries the previous frame's numbers; asserting them from
957
+ * the tree would read a stale value rather than the stack.
958
+ */
959
+ it('stacks by default — the deliberate deviation from sonner-native', () => {
960
+ expect(toastDefaults.enableStacking).toBe(true);
961
+ });
962
+
963
+ /**
964
+ * SEQUENTIAL MULTI-TOAST AT THE DEFAULT DURATION.
965
+ *
966
+ * Every earlier sequential check — the `Sequential*` stories and the browser
967
+ * probes driving them — fires with `duration: Infinity`, which starts no
968
+ * auto-close timer at all. That made the whole timer path invisible to them:
969
+ * a defect that cut a settled row's life short when the next toast arrived
970
+ * would have left every one of those results green. These tests therefore use
971
+ * the OUTLET DEFAULT duration and assert lifetimes, not just presence.
972
+ *
973
+ * Both `enableStacking` values run: the flat and collapsed-stack paths resolve
974
+ * row positions through different branches of `calculateToastPosition`, and a
975
+ * row dropped from the render set in one but not the other would otherwise be
976
+ * a coin flip on the default.
977
+ *
978
+ * These are RENDER-SET assertions. A row that is mounted but visually pinned or
979
+ * collapsed by reanimated's web layout-animation cleanup still passes here —
980
+ * that class of bug is only observable in a real browser, which is what the
981
+ * `Sequential*` stories and `animations.ts`'s `TOAST_ENTER_DRIVER` note cover.
982
+ */
983
+ describe.each([false, true])(
984
+ 'sequential toasts at the default duration (enableStacking=%s)',
985
+ (enableStacking) => {
986
+ /** The window the consumer report measured the first row's disappearance in. */
987
+ const REPORT_WINDOW = 150;
988
+ const GAP = 1000;
989
+
990
+ it('keeps both rows mounted when the second arrives while the first is at rest', () => {
991
+ const rendered = renderOutlet({ enableStacking });
992
+ show(() => toast('first'));
993
+ act(() => {
994
+ jest.advanceTimersByTime(GAP);
995
+ });
996
+ show(() => toast('second'));
997
+ act(() => {
998
+ jest.advanceTimersByTime(REPORT_WINDOW);
999
+ });
1000
+
1001
+ expect(rendered.queryByText('first')).toBeTruthy();
1002
+ expect(rendered.queryByText('second')).toBeTruthy();
1003
+ expect(rowBoxesOf(rendered)).toHaveLength(2);
1004
+ });
1005
+
1006
+ it('does not shorten the first row: it lives its own full duration, no less', () => {
1007
+ const rendered = renderOutlet({ enableStacking });
1008
+ show(() => toast('first'));
1009
+ act(() => {
1010
+ jest.advanceTimersByTime(GAP);
1011
+ });
1012
+ show(() => toast('second'));
1013
+
1014
+ // One tick short of the first row's own deadline (its duration plus the
1015
+ // enter animation's head start), measured from ITS fire, not the second's.
1016
+ act(() => {
1017
+ jest.advanceTimersByTime(
1018
+ toastDefaults.duration + ENTERING_ANIMATION_DURATION - GAP - 1,
1019
+ );
1020
+ });
1021
+ expect(rendered.queryByText('first')).toBeTruthy();
1022
+ expect(rowBoxesOf(rendered)).toHaveLength(2);
1023
+
1024
+ act(() => {
1025
+ jest.advanceTimersByTime(1);
1026
+ });
1027
+ expect(rendered.queryByText('first')).toBeNull();
1028
+ expect(rendered.queryByText('second')).toBeTruthy();
1029
+ expect(rowBoxesOf(rendered)).toHaveLength(1);
1030
+ });
1031
+
1032
+ it('does not extend the first row either, nor cut the second short', () => {
1033
+ const rendered = renderOutlet({ enableStacking });
1034
+ show(() => toast('first'));
1035
+ act(() => {
1036
+ jest.advanceTimersByTime(GAP);
1037
+ });
1038
+ show(() => toast('second'));
1039
+
1040
+ // The second row's own deadline is a full GAP later than the first's.
1041
+ act(() => {
1042
+ jest.advanceTimersByTime(
1043
+ toastDefaults.duration + ENTERING_ANIMATION_DURATION - 1,
1044
+ );
1045
+ });
1046
+ expect(rendered.queryByText('first')).toBeNull();
1047
+ expect(rendered.queryByText('second')).toBeTruthy();
1048
+
1049
+ act(() => {
1050
+ jest.advanceTimersByTime(1);
1051
+ });
1052
+ expect(rendered.queryByText('second')).toBeNull();
1053
+ expect(rowBoxesOf(rendered)).toHaveLength(0);
1054
+ });
1055
+
1056
+ it('holds every row of a three-toast run, one arriving each second', () => {
1057
+ const rendered = renderOutlet({ enableStacking });
1058
+ show(() => toast('first'));
1059
+ act(() => {
1060
+ jest.advanceTimersByTime(GAP);
1061
+ });
1062
+ show(() => toast('second'));
1063
+ act(() => {
1064
+ jest.advanceTimersByTime(GAP);
1065
+ });
1066
+ show(() => toast('third'));
1067
+ act(() => {
1068
+ jest.advanceTimersByTime(REPORT_WINDOW);
1069
+ });
1070
+
1071
+ expect(rowBoxesOf(rendered)).toHaveLength(3);
1072
+ expect(rendered.queryByText('first')).toBeTruthy();
1073
+ expect(rendered.queryByText('second')).toBeTruthy();
1074
+ expect(rendered.queryByText('third')).toBeTruthy();
1075
+ });
1076
+ },
1077
+ );
1078
+
525
1079
  describe('app-state pausing', () => {
526
1080
  it('subscribes exactly once for the whole stack, not once per row', () => {
527
1081
  const addEventListener = jest.spyOn(AppState, 'addEventListener');
@@ -196,6 +196,34 @@ describe('calculateToastPosition', () => {
196
196
  }),
197
197
  ).toBe(54);
198
198
  });
199
+
200
+ /**
201
+ * WHY THE STORE MAY AUTO-COLLAPSE A STACK DOWN TO ITS LAST ROW.
202
+ *
203
+ * `ToastStore.dismissToast` collapses an expanded stack once one row is left, so
204
+ * that row's paused timer resumes and it cannot hang on screen forever. That is
205
+ * only acceptable because the collapse is INVISIBLE at one row: both branches of
206
+ * `calculateToastPosition` resolve to the same offset, and `calculateStackScaleX`
207
+ * returns 1 either way. If this ever stops holding, the store's rule needs a
208
+ * rethink rather than this test an update.
209
+ */
210
+ it.each<ToastPosition>(['bottom-center', 'top-center', 'center'])(
211
+ 'a single row is unmoved by expansion (%s)',
212
+ (position) => {
213
+ const lone = {
214
+ index: 0,
215
+ position,
216
+ enableStacking: true,
217
+ numberOfToasts: 1,
218
+ orderedToastIds: ['a'],
219
+ allToastHeights: { a: 50 },
220
+ };
221
+
222
+ expect(positionAt({ ...lone, isExpanded: true })).toBe(
223
+ positionAt({ ...lone, isExpanded: false }),
224
+ );
225
+ },
226
+ );
199
227
  });
200
228
  });
201
229