@tangle-network/ui 11.15.2 → 11.15.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.15.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 2ecce2e: Cancel pending toast auto-dismiss timers when a toast is dismissed or its provider unmounts.
8
+
3
9
  ## 11.15.2
4
10
 
5
11
  ### Patch Changes
@@ -67,7 +67,20 @@ function ToastContainer({ toasts, onDismiss }) {
67
67
  const ToastContext = React.createContext(null);
68
68
  function ToastProvider({ children }) {
69
69
  const [toasts, setToasts] = React.useState([]);
70
+ const timers = React.useRef(/* @__PURE__ */ new Map());
71
+ React.useEffect(() => {
72
+ const pendingTimers = timers.current;
73
+ return () => {
74
+ for (const timer of pendingTimers.values()) clearTimeout(timer);
75
+ pendingTimers.clear();
76
+ };
77
+ }, []);
70
78
  const dismiss = React.useCallback((id) => {
79
+ const timer = timers.current.get(id);
80
+ if (timer !== void 0) {
81
+ clearTimeout(timer);
82
+ timers.current.delete(id);
83
+ }
71
84
  setToasts((prev) => prev.filter((t) => t.id !== id));
72
85
  }, []);
73
86
  const toast = React.useCallback((input) => {
@@ -78,7 +91,7 @@ function ToastProvider({ children }) {
78
91
  };
79
92
  setToasts((prev) => [...prev, newToast]);
80
93
  const duration = input.duration ?? 5e3;
81
- if (duration > 0) setTimeout(() => dismiss(id), duration);
94
+ if (duration > 0) timers.current.set(id, setTimeout(() => dismiss(id), duration));
82
95
  }, [dismiss]);
83
96
  const success = React.useCallback((title, description) => {
84
97
  toast({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.15.2",
3
+ "version": "11.15.3",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,130 @@
1
+ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { ToastProvider, useToast } from "./toast";
4
+
5
+ function ToastTrigger({ title, duration }: { title: string; duration?: number }) {
6
+ const { toast } = useToast();
7
+
8
+ return (
9
+ <button type="button" onClick={() => toast({ title, duration })}>
10
+ Show {title}
11
+ </button>
12
+ );
13
+ }
14
+
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ });
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ vi.restoreAllMocks();
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ describe("ToastProvider auto-dismiss", () => {
26
+ it("cancels every pending dismissal on unmount without updating after teardown", () => {
27
+ const view = render(
28
+ <ToastProvider>
29
+ <ToastTrigger title="First" duration={1000} />
30
+ <ToastTrigger title="Second" duration={2000} />
31
+ </ToastProvider>,
32
+ );
33
+ const scheduleTimeout = globalThis.setTimeout;
34
+ const timeoutFired = vi.fn();
35
+ // Observe the real dismissal callbacks without mocking the provider or React state.
36
+ vi.spyOn(globalThis, "setTimeout").mockImplementation((callback, delay, ...args) =>
37
+ scheduleTimeout(() => {
38
+ timeoutFired();
39
+ callback(...args);
40
+ }, delay),
41
+ );
42
+
43
+ fireEvent.click(screen.getByRole("button", { name: "Show First" }));
44
+ fireEvent.click(screen.getByRole("button", { name: "Show Second" }));
45
+ expect(screen.getAllByRole("alert")).toHaveLength(2);
46
+ expect(vi.getTimerCount()).toBe(2);
47
+
48
+ view.unmount();
49
+ expect(vi.getTimerCount()).toBe(0);
50
+
51
+ act(() => {
52
+ vi.advanceTimersByTime(5000);
53
+ });
54
+ // A cancelled callback cannot reach dismiss() or its state update after unmount.
55
+ expect(timeoutFired).not.toHaveBeenCalled();
56
+ expect(vi.getTimerCount()).toBe(0);
57
+ });
58
+
59
+ it("keeps a toast until its duration elapses, then removes it", () => {
60
+ render(
61
+ <ToastProvider>
62
+ <ToastTrigger title="Saved" duration={1000} />
63
+ </ToastProvider>,
64
+ );
65
+
66
+ fireEvent.click(screen.getByRole("button", { name: "Show Saved" }));
67
+ expect(vi.getTimerCount()).toBe(1);
68
+
69
+ act(() => {
70
+ vi.advanceTimersByTime(999);
71
+ });
72
+ expect(screen.getByRole("alert")).toHaveTextContent("Saved");
73
+
74
+ act(() => {
75
+ vi.advanceTimersByTime(1);
76
+ });
77
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
78
+ expect(vi.getTimerCount()).toBe(0);
79
+ });
80
+
81
+ it("clears only the manually dismissed toast's timer", () => {
82
+ render(
83
+ <ToastProvider>
84
+ <ToastTrigger title="First" duration={1000} />
85
+ <ToastTrigger title="Second" duration={2000} />
86
+ </ToastProvider>,
87
+ );
88
+
89
+ fireEvent.click(screen.getByRole("button", { name: "Show First" }));
90
+ fireEvent.click(screen.getByRole("button", { name: "Show Second" }));
91
+ expect(vi.getTimerCount()).toBe(2);
92
+
93
+ fireEvent.click(within(screen.getAllByRole("alert")[0]!).getByRole("button", {
94
+ name: "Dismiss notification",
95
+ }));
96
+ expect(screen.queryByText("First")).not.toBeInTheDocument();
97
+ expect(screen.getByRole("alert")).toHaveTextContent("Second");
98
+ expect(vi.getTimerCount()).toBe(1);
99
+
100
+ act(() => {
101
+ vi.advanceTimersByTime(1000);
102
+ });
103
+ expect(screen.getByRole("alert")).toHaveTextContent("Second");
104
+
105
+ act(() => {
106
+ vi.advanceTimersByTime(1000);
107
+ });
108
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
109
+ expect(vi.getTimerCount()).toBe(0);
110
+ });
111
+
112
+ it("schedules no timer when duration is zero", () => {
113
+ render(
114
+ <ToastProvider>
115
+ <ToastTrigger title="Persistent" duration={0} />
116
+ </ToastProvider>,
117
+ );
118
+ const scheduleTimeout = vi.spyOn(globalThis, "setTimeout");
119
+
120
+ fireEvent.click(screen.getByRole("button", { name: "Show Persistent" }));
121
+ expect(scheduleTimeout).not.toHaveBeenCalled();
122
+ expect(vi.getTimerCount()).toBe(0);
123
+
124
+ act(() => {
125
+ vi.advanceTimersByTime(10000);
126
+ });
127
+ expect(screen.getByRole("alert")).toHaveTextContent("Persistent");
128
+ expect(vi.getTimerCount()).toBe(0);
129
+ });
130
+ });
@@ -129,8 +129,24 @@ const ToastContext = React.createContext<ToastContextValue | null>(null);
129
129
 
130
130
  export function ToastProvider({ children }: { children: React.ReactNode }) {
131
131
  const [toasts, setToasts] = React.useState<Toast[]>([]);
132
+ const timers = React.useRef(new Map<string, ReturnType<typeof setTimeout>>());
133
+
134
+ React.useEffect(() => {
135
+ const pendingTimers = timers.current;
136
+ return () => {
137
+ for (const timer of pendingTimers.values()) {
138
+ clearTimeout(timer);
139
+ }
140
+ pendingTimers.clear();
141
+ };
142
+ }, []);
132
143
 
133
144
  const dismiss = React.useCallback((id: string) => {
145
+ const timer = timers.current.get(id);
146
+ if (timer !== undefined) {
147
+ clearTimeout(timer);
148
+ timers.current.delete(id);
149
+ }
134
150
  setToasts((prev) => prev.filter((t) => t.id !== id));
135
151
  }, []);
136
152
 
@@ -143,7 +159,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
143
159
  // Auto-dismiss after duration
144
160
  const duration = input.duration ?? 5000;
145
161
  if (duration > 0) {
146
- setTimeout(() => dismiss(id), duration);
162
+ timers.current.set(id, setTimeout(() => dismiss(id), duration));
147
163
  }
148
164
  },
149
165
  [dismiss],