Skip to content

Commit 1e4e89b

Browse files
authored
Merge pull request #2 from y0u-0/toast-deduplication
2 parents 4f2930f + b5657b1 commit 1e4e89b

File tree

10 files changed

+187
-50
lines changed

10 files changed

+187
-50
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ toast.success('Saved!', {
141141
style: { backgroundColor: '#fff' },
142142
dismissible: true,
143143
showCloseButton: true,
144+
deduplication: false, // Opt out of deduplication for this toast
144145
});
145146
```
146147

@@ -202,10 +203,41 @@ Available options include:
202203
- **dismissible**: Allow swipe to dismiss
203204
- **showCloseButton**: Show X button
204205
- **defaultDuration**: Default display time in ms
206+
- **deduplication**: Prevent duplicate toasts (default: `true`, see below)
205207
- **colors**: Custom colors per toast type
206208
- **icons**: Custom icons per toast type
207209
- **toastStyle**, **titleStyle**, **descriptionStyle**: Global style overrides
208210

211+
### Deduplication
212+
213+
Deduplication is **enabled by default**. When the same toast is shown repeatedly (e.g., rapid button taps), it prevents stacking identical toasts. Instead, it resets the timer and plays a feedback animation:
214+
215+
- **Non-error toasts**: subtle pulse (scale bump)
216+
- **Error toasts**: shake effect
217+
218+
Disable globally:
219+
220+
```tsx
221+
<BreadLoaf config={{ deduplication: false }} />
222+
```
223+
224+
Or per-toast (overrides global config):
225+
226+
```tsx
227+
// Opt out for a specific toast
228+
toast.info('New message', { deduplication: false });
229+
230+
// Explicitly enable for a specific toast (redundant when global is on)
231+
toast.success('Liked!', { deduplication: true });
232+
```
233+
234+
By default, a toast is considered a duplicate when it matches the **front toast** by title, type, and description. For stable matching across different content, provide an `id` — the existing toast's content will be updated:
235+
236+
```tsx
237+
toast.success('Saved item 1', { deduplication: true, id: 'save-action' });
238+
toast.success('Saved item 2', { deduplication: true, id: 'save-action' }); // updates content, resets timer
239+
```
240+
209241
## API Reference
210242

211243
| Method | Description |

example/app/(custom)/index.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,30 @@ export default function CustomScreen() {
112112
<Text style={styles.buttonText}>No Close Button</Text>
113113
</TouchableOpacity>
114114

115+
<TouchableOpacity
116+
style={[styles.button, { backgroundColor: "#f59e0b" }]}
117+
onPress={() =>
118+
toast.success("Liked!", {
119+
description: "Tap again — it won't stack",
120+
deduplication: true,
121+
})
122+
}
123+
>
124+
<Text style={styles.buttonText}>Deduplication (Pulse)</Text>
125+
</TouchableOpacity>
126+
127+
<TouchableOpacity
128+
style={[styles.button, { backgroundColor: "#dc2626" }]}
129+
onPress={() =>
130+
toast.error("Rate limited", {
131+
description: "Please wait before retrying",
132+
deduplication: true,
133+
})
134+
}
135+
>
136+
<Text style={styles.buttonText}>Deduplication (Shake)</Text>
137+
</TouchableOpacity>
138+
115139
<TouchableOpacity
116140
style={[styles.button, { backgroundColor: "#8b5cf6" }]}
117141
onPress={() =>

example/app/(global)/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default function GlobalConfigScreen() {
1515
const [showCloseButton, setShowCloseButton] = useState(true);
1616
const [customStyle, setCustomStyle] = useState(true);
1717
const [rtl, setRtl] = useState(false);
18+
const [deduplication, setDeduplication] = useState(true);
1819

1920
const showToast = () => {
2021
toast.success("Hello!", "This toast uses the global config");
@@ -38,6 +39,7 @@ export default function GlobalConfigScreen() {
3839
rtl,
3940
offset: 8,
4041
defaultDuration: 4000,
42+
deduplication,
4143
...(customStyle && {
4244
toastStyle: {
4345
borderRadius: 30,
@@ -146,6 +148,14 @@ export default function GlobalConfigScreen() {
146148
<Switch value={rtl} onValueChange={setRtl} />
147149
</View>
148150

151+
<View style={styles.option}>
152+
<View>
153+
<Text style={styles.optionLabel}>Deduplication</Text>
154+
<Text style={styles.optionDesc}>Pulse/shake on repeated toasts</Text>
155+
</View>
156+
<Switch value={deduplication} onValueChange={setDeduplication} />
157+
</View>
158+
149159
<View style={styles.option}>
150160
<View>
151161
<Text style={styles.optionLabel}>Custom Styling</Text>

package/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ export const DISMISS_VELOCITY_THRESHOLD = 300;
2020
export const STACK_OFFSET_PER_ITEM = 10;
2121
export const STACK_SCALE_PER_ITEM = 0.05;
2222

23+
export const DEDUPLICATION_PULSE_DURATION = 300;
24+
export const DEDUPLICATION_SHAKE_DURATION = 400;
25+
2326
export const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0);

package/src/pool.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,14 @@ export interface AnimSlot {
55
progress: SharedValue<number>;
66
translationY: SharedValue<number>;
77
stackIndex: SharedValue<number>;
8-
}
9-
10-
export interface SlotTracker {
11-
wasExiting: boolean;
12-
prevIndex: number;
13-
initialized: boolean;
8+
deduplication: SharedValue<number>;
149
}
1510

1611
export const animationPool: AnimSlot[] = Array.from({ length: POOL_SIZE }, () => ({
1712
progress: makeMutable(0),
1813
translationY: makeMutable(0),
1914
stackIndex: makeMutable(0),
20-
}));
21-
22-
export const slotTrackers: SlotTracker[] = Array.from({ length: POOL_SIZE }, () => ({
23-
wasExiting: false,
24-
prevIndex: 0,
25-
initialized: false,
15+
deduplication: makeMutable(0),
2616
}));
2717

2818
const slotAssignments = new Map<string, number>();
@@ -36,9 +26,6 @@ export const getSlotIndex = (toastId: string): number => {
3626
if (!usedSlots.has(i)) {
3727
slotAssignments.set(toastId, i);
3828
usedSlots.add(i);
39-
slotTrackers[i].initialized = false;
40-
slotTrackers[i].wasExiting = false;
41-
slotTrackers[i].prevIndex = 0;
4229
return i;
4330
}
4431
}
@@ -50,8 +37,5 @@ export const releaseSlot = (toastId: string) => {
5037
if (idx !== undefined) {
5138
usedSlots.delete(idx);
5239
slotAssignments.delete(toastId);
53-
slotTrackers[idx].initialized = false;
54-
slotTrackers[idx].wasExiting = false;
55-
slotTrackers[idx].prevIndex = 0;
5640
}
5741
};

package/src/toast-icons.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const AnimatedIcon = memo(
4646
}, [progress]);
4747

4848
const style = useAnimatedStyle(() => ({
49-
opacity: progress.value,
50-
transform: [{ scale: 0.7 + progress.value * 0.3 }],
49+
opacity: progress.get(),
50+
transform: [{ scale: 0.7 + progress.get() * 0.3 }],
5151
}));
5252

5353
return <Animated.View style={style}>{resolveIcon(type, color, custom, config)}</Animated.View>;

package/src/toast-provider.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ interface BreadLoafProps {
2020
*
2121
* @property position - Where toasts appear: `'top'` (default) or `'bottom'`
2222
* @property offset - Extra spacing from screen edge in pixels (default: `0`)
23+
* @property rtl - Enable right-to-left layout (default: `false`)
2324
* @property stacking - Show multiple toasts stacked (default: `true`). When `false`, only one toast shows at a time
25+
* @property maxStack - Maximum visible toasts when stacking (default: `3`)
26+
* @property dismissible - Whether toasts can be swiped to dismiss (default: `true`)
27+
* @property showCloseButton - Show close button on toasts (default: `true`)
2428
* @property defaultDuration - Default display time in ms (default: `4000`)
29+
* @property deduplication - Deduplicate repeated toasts, resetting timer with pulse/shake animation (default: `true`)
2530
* @property colors - Customize colors per toast type (`success`, `error`, `info`, `loading`)
31+
* @property icons - Custom icons per toast type
2632
* @property toastStyle - Style overrides for the toast container (borderRadius, shadow, padding, etc.)
2733
* @property titleStyle - Style overrides for the title text
2834
* @property descriptionStyle - Style overrides for the description text

package/src/toast-store.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_THEME: ToastTheme = {
2323
titleStyle: {},
2424
descriptionStyle: {},
2525
defaultDuration: 4000,
26+
deduplication: true,
2627
};
2728

2829
function mergeConfig(config: ToastConfig | undefined): ToastTheme {
@@ -55,6 +56,7 @@ function mergeConfig(config: ToastConfig | undefined): ToastTheme {
5556
titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle },
5657
descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle },
5758
defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration,
59+
deduplication: config.deduplication ?? DEFAULT_THEME.deduplication,
5860
};
5961
}
6062

@@ -104,12 +106,38 @@ class ToastStore {
104106
): string => {
105107
const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration;
106108
const maxToasts = this.theme.stacking ? this.theme.maxStack : 1;
109+
const resolvedDescription = description ?? options?.description;
110+
111+
const shouldDedup = type !== "loading" && (options?.deduplication ?? this.theme.deduplication);
112+
if (shouldDedup) {
113+
const deduplicationId = options?.id;
114+
const frontToast = this.state.visibleToasts.find(t => !t.isExiting);
115+
const duplicate = deduplicationId
116+
? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId)
117+
: frontToast &&
118+
frontToast.title === title &&
119+
frontToast.type === type &&
120+
frontToast.description === resolvedDescription
121+
? frontToast
122+
: undefined;
123+
124+
if (duplicate) {
125+
this.updateToast(duplicate.id, {
126+
title,
127+
description: resolvedDescription,
128+
type,
129+
deduplicatedAt: Date.now(),
130+
duration: actualDuration,
131+
});
132+
return duplicate.id;
133+
}
134+
}
107135

108136
const id = `toast-${++this.toastIdCounter}`;
109137
const newToast: Toast = {
110138
id,
111139
title,
112-
description: description ?? options?.description,
140+
description: resolvedDescription,
113141
type,
114142
duration: actualDuration,
115143
createdAt: Date.now(),

0 commit comments

Comments
 (0)