1import { act, type ReactNode } from "react";
2import { createRoot, type Root } from "react-dom/client";
3import { afterEach, expect, it, vi } from "vite-plus/test";
4
5// Every click handler a button was rendered with, so a test can deliver a
6// native event that was dispatched against an older render.
7const renderedClicks = vi.hoisted(() => [] as Array<() => void>);
8
9vi.mock("@expo/ui/jetpack-compose", () => {
10 const Slot = (props: { readonly children?: ReactNode }) => <div>{props.children}</div>;
11 const Button = (props: { readonly children?: ReactNode; readonly onClick?: () => void }) => {
12 if (props.onClick) renderedClicks.push(props.onClick);
13 return <button onClick={props.onClick}>{props.children}</button>;
14 };
15 return {
16 AlertDialog: Object.assign(Slot, {
17 Title: Slot,
18 Text: Slot,
19 DismissButton: Slot,
20 ConfirmButton: Slot,
21 }),
22 Button,
23 OutlinedButton: Button,
24 Column: Slot,
25 FlowRow: Slot,
26 Icon: () => null,
27 OutlinedTextField: Object.assign(Slot, { Label: Slot, Placeholder: Slot }),
28 Text: Slot,
29 useNativeState: (value: unknown) => ({ get: () => value }),
30 };
31});
32vi.mock("./NativeThemeHost.android", () => ({
33 NativeThemeHost: (props: { readonly children?: ReactNode }) => <div>{props.children}</div>,
34}));
35vi.mock("../../features/settings/appearance/AppearancePreferencesProvider", () => ({
36 useAppearancePreferences: () => ({ themeVariables: {} }),
37}));
38vi.mock("../../features/settings/appearance/useScaledTextRole", () => ({
39 useScaledTextRole: () => ({}),
40}));
41vi.mock("../../lib/mobileTheme", () => ({ themeColorWithContrast: () => "#ffffff" }));
42
43import * as Dialog from "./Dialog.android";
44
45const roots: Root[] = [];
46
47function mountHost() {
48 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
49 const container = document.createElement("div");
50 document.body.append(container);
51 const root = createRoot(container);
52 roots.push(root);
53 act(() => root.render(<Dialog.Host />));
54 return { root, container };
55}
56
57afterEach(() => {
58 for (const root of roots.splice(0)) act(() => root.unmount());
59 renderedClicks.length = 0;
60 document.body.innerHTML = "";
61});
62
63it("keeps presenting dialogs after an older root's Host unmounts", () => {
64 // Android activity replacement mounts the new root before the old one stops.
65 const previous = mountHost();
66 const current = mountHost();
67 roots.splice(roots.indexOf(previous.root), 1);
68 act(() => previous.root.unmount());
69
70 act(() => Dialog.alert("Saved", undefined, [{ text: "OK" }]));
71
72 expect(current.container.textContent).toContain("Saved");
73});
74
75it("presents a dialog requested before any Host mounted", () => {
76 Dialog.alert("Could not import shared content", undefined, [{ text: "Dismiss" }]);
77
78 const { container } = mountHost();
79
80 expect(container.textContent).toContain("Could not import shared content");
81});
82
83it("keeps a dialog raised while the activity is replaced", () => {
84 const previous = mountHost();
85 act(() => Dialog.alert("Shared content imported", undefined, [{ text: "OK" }]));
86 const current = mountHost();
87 roots.splice(roots.indexOf(previous.root), 1);
88 act(() => previous.root.unmount());
89
90 expect(current.container.textContent).toContain("Shared content imported");
91});
92
93it("ignores a late action from a dialog that a newer one replaced", () => {
94 const { container } = mountHost();
95 const olderAction = vi.fn();
96 act(() => Dialog.alert("Older", undefined, [{ text: "OK", onPress: olderAction }]));
97 const olderClick = renderedClicks.at(-1)!;
98 act(() => Dialog.alert("Newer", undefined, [{ text: "OK" }]));
99
100 act(() => olderClick());
101
102 expect(olderAction).toHaveBeenCalledOnce();
103 expect(container.textContent).toContain("Newer");
104});