Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ toast.success('Saved!', {
style: { backgroundColor: '#fff' },
dismissible: true,
showCloseButton: true,
deduplication: false, // Opt out of deduplication for this toast
});
```

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

### Deduplication

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:

- **Non-error toasts**: subtle pulse (scale bump)
- **Error toasts**: shake effect

Disable globally:

```tsx
<BreadLoaf config={{ deduplication: false }} />
```

Or per-toast (overrides global config):

```tsx
// Opt out for a specific toast
toast.info('New message', { deduplication: false });

// Explicitly enable for a specific toast (redundant when global is on)
toast.success('Liked!', { deduplication: true });
```

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:

```tsx
toast.success('Saved item 1', { deduplication: true, id: 'save-action' });
toast.success('Saved item 2', { deduplication: true, id: 'save-action' }); // updates content, resets timer
```

## API Reference

| Method | Description |
Expand Down
24 changes: 24 additions & 0 deletions example/app/(custom)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,30 @@ export default function CustomScreen() {
<Text style={styles.buttonText}>No Close Button</Text>
</TouchableOpacity>

<TouchableOpacity
style={[styles.button, { backgroundColor: "#f59e0b" }]}
onPress={() =>
toast.success("Liked!", {
description: "Tap again — it won't stack",
deduplication: true,
})
}
>
<Text style={styles.buttonText}>Deduplication (Pulse)</Text>
</TouchableOpacity>

<TouchableOpacity
style={[styles.button, { backgroundColor: "#dc2626" }]}
onPress={() =>
toast.error("Rate limited", {
description: "Please wait before retrying",
deduplication: true,
})
}
>
<Text style={styles.buttonText}>Deduplication (Shake)</Text>
</TouchableOpacity>

<TouchableOpacity
style={[styles.button, { backgroundColor: "#8b5cf6" }]}
onPress={() =>
Expand Down
10 changes: 10 additions & 0 deletions example/app/(global)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default function GlobalConfigScreen() {
const [showCloseButton, setShowCloseButton] = useState(true);
const [customStyle, setCustomStyle] = useState(true);
const [rtl, setRtl] = useState(false);
const [deduplication, setDeduplication] = useState(true);

const showToast = () => {
toast.success("Hello!", "This toast uses the global config");
Expand All @@ -38,6 +39,7 @@ export default function GlobalConfigScreen() {
rtl,
offset: 8,
defaultDuration: 4000,
deduplication,
...(customStyle && {
toastStyle: {
borderRadius: 30,
Expand Down Expand Up @@ -146,6 +148,14 @@ export default function GlobalConfigScreen() {
<Switch value={rtl} onValueChange={setRtl} />
</View>

<View style={styles.option}>
<View>
<Text style={styles.optionLabel}>Deduplication</Text>
<Text style={styles.optionDesc}>Pulse/shake on repeated toasts</Text>
</View>
<Switch value={deduplication} onValueChange={setDeduplication} />
</View>

<View style={styles.option}>
<View>
<Text style={styles.optionLabel}>Custom Styling</Text>
Expand Down
3 changes: 3 additions & 0 deletions package/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ export const DISMISS_VELOCITY_THRESHOLD = 300;
export const STACK_OFFSET_PER_ITEM = 10;
export const STACK_SCALE_PER_ITEM = 0.05;

export const DEDUPLICATION_PULSE_DURATION = 300;
export const DEDUPLICATION_SHAKE_DURATION = 400;

export const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0);
20 changes: 2 additions & 18 deletions package/src/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,14 @@ export interface AnimSlot {
progress: SharedValue<number>;
translationY: SharedValue<number>;
stackIndex: SharedValue<number>;
}

export interface SlotTracker {
wasExiting: boolean;
prevIndex: number;
initialized: boolean;
deduplication: SharedValue<number>;
}

export const animationPool: AnimSlot[] = Array.from({ length: POOL_SIZE }, () => ({
progress: makeMutable(0),
translationY: makeMutable(0),
stackIndex: makeMutable(0),
}));

export const slotTrackers: SlotTracker[] = Array.from({ length: POOL_SIZE }, () => ({
wasExiting: false,
prevIndex: 0,
initialized: false,
deduplication: makeMutable(0),
}));

const slotAssignments = new Map<string, number>();
Expand All @@ -36,9 +26,6 @@ export const getSlotIndex = (toastId: string): number => {
if (!usedSlots.has(i)) {
slotAssignments.set(toastId, i);
usedSlots.add(i);
slotTrackers[i].initialized = false;
slotTrackers[i].wasExiting = false;
slotTrackers[i].prevIndex = 0;
return i;
}
}
Expand All @@ -50,8 +37,5 @@ export const releaseSlot = (toastId: string) => {
if (idx !== undefined) {
usedSlots.delete(idx);
slotAssignments.delete(toastId);
slotTrackers[idx].initialized = false;
slotTrackers[idx].wasExiting = false;
slotTrackers[idx].prevIndex = 0;
}
};
4 changes: 2 additions & 2 deletions package/src/toast-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const AnimatedIcon = memo(
}, [progress]);

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

return <Animated.View style={style}>{resolveIcon(type, color, custom, config)}</Animated.View>;
Expand Down
6 changes: 6 additions & 0 deletions package/src/toast-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ interface BreadLoafProps {
*
* @property position - Where toasts appear: `'top'` (default) or `'bottom'`
* @property offset - Extra spacing from screen edge in pixels (default: `0`)
* @property rtl - Enable right-to-left layout (default: `false`)
* @property stacking - Show multiple toasts stacked (default: `true`). When `false`, only one toast shows at a time
* @property maxStack - Maximum visible toasts when stacking (default: `3`)
* @property dismissible - Whether toasts can be swiped to dismiss (default: `true`)
* @property showCloseButton - Show close button on toasts (default: `true`)
* @property defaultDuration - Default display time in ms (default: `4000`)
* @property deduplication - Deduplicate repeated toasts, resetting timer with pulse/shake animation (default: `true`)
* @property colors - Customize colors per toast type (`success`, `error`, `info`, `loading`)
* @property icons - Custom icons per toast type
* @property toastStyle - Style overrides for the toast container (borderRadius, shadow, padding, etc.)
* @property titleStyle - Style overrides for the title text
* @property descriptionStyle - Style overrides for the description text
Expand Down
30 changes: 29 additions & 1 deletion package/src/toast-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const DEFAULT_THEME: ToastTheme = {
titleStyle: {},
descriptionStyle: {},
defaultDuration: 4000,
deduplication: true,
};

function mergeConfig(config: ToastConfig | undefined): ToastTheme {
Expand Down Expand Up @@ -55,6 +56,7 @@ function mergeConfig(config: ToastConfig | undefined): ToastTheme {
titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle },
descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle },
defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration,
deduplication: config.deduplication ?? DEFAULT_THEME.deduplication,
};
}

Expand Down Expand Up @@ -104,12 +106,38 @@ class ToastStore {
): string => {
const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration;
const maxToasts = this.theme.stacking ? this.theme.maxStack : 1;
const resolvedDescription = description ?? options?.description;

const shouldDedup = type !== "loading" && (options?.deduplication ?? this.theme.deduplication);
if (shouldDedup) {
const deduplicationId = options?.id;
const frontToast = this.state.visibleToasts.find(t => !t.isExiting);
const duplicate = deduplicationId
? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId)
: frontToast &&
frontToast.title === title &&
frontToast.type === type &&
frontToast.description === resolvedDescription
? frontToast
: undefined;

if (duplicate) {
this.updateToast(duplicate.id, {
title,
description: resolvedDescription,
type,
deduplicatedAt: Date.now(),
duration: actualDuration,
});
return duplicate.id;
}
}

const id = `toast-${++this.toastIdCounter}`;
const newToast: Toast = {
id,
title,
description: description ?? options?.description,
description: resolvedDescription,
type,
duration: actualDuration,
createdAt: Date.now(),
Expand Down
Loading