Skip to content

Commit 71e9153

Browse files
committed
perf(game-client): optimize notifications rendering
1 parent 2a4ce13 commit 71e9153

8 files changed

Lines changed: 337 additions & 106 deletions

File tree

apps/game-client/src/features/notifications/components/notifications-list.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ export const NotificationsList: FC<NotificationsListProps> = ({
1919
const guildNamesById = getGuildNamesById(guilds);
2020
const notificationsCount = notifications?.length ?? 0;
2121
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
22-
const activeNotificationAnimations = useNotificationsStore(
23-
(state) => state.activeNotificationAnimations,
24-
);
2522
const latestNotificationAnimationCycle = useNotificationsStore(
2623
(state) => state.latestNotificationAnimationCycle,
2724
);
@@ -37,10 +34,7 @@ export const NotificationsList: FC<NotificationsListProps> = ({
3734
className="ll:h-full ll:w-full ll:box-border"
3835
type="hover"
3936
>
40-
<motion.div
41-
layout
42-
className="ll:flex ll:w-full ll:flex-col ll:gap-1 ll:pt-1"
43-
>
37+
<motion.div className="ll:flex ll:w-full ll:flex-col ll:gap-1 ll:pt-1">
4438
<AnimatePresence initial={false}>
4539
{notifications?.map((notification) => {
4640
return (
@@ -65,9 +59,6 @@ export const NotificationsList: FC<NotificationsListProps> = ({
6559
>
6660
<SingleNotification
6761
notification={notification}
68-
notificationAnimationCycle={
69-
activeNotificationAnimations[notification.listKey] ?? null
70-
}
7162
guildNamesById={guildNamesById}
7263
showCloseButton={notificationsCount > 1}
7364
/>

apps/game-client/src/features/notifications/components/single-notification.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ const AUTO_HIDE_PROGRESS_STROKE_OPACITY = 0.95;
4040
type SingleNotificationProps = {
4141
guildNamesById: Record<string, string>;
4242
notification: StoredNotification;
43-
notificationAnimationCycle: number | null;
4443
showCloseButton?: boolean;
4544
};
4645

@@ -115,7 +114,6 @@ const renderNotificationContent = ({
115114
export const SingleNotification: FC<SingleNotificationProps> = ({
116115
guildNamesById,
117116
notification,
118-
notificationAnimationCycle: _notificationAnimationCycle,
119117
showCloseButton = false,
120118
}) => {
121119
const removeNotification = useNotificationsStore(
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
type StoredNotification,
5+
useNotificationsStore,
6+
} from "@/store/notifications.store";
7+
import { useVisibleNotifications } from "./use-visible-notifications";
8+
9+
const mockUseCurrentGameAccountNotificationSettings = vi.fn();
10+
11+
vi.mock("@/hooks/use-current-game-account-notification-settings", () => ({
12+
useCurrentGameAccountNotificationSettings: () =>
13+
mockUseCurrentGameAccountNotificationSettings(),
14+
}));
15+
16+
vi.mock("@/lib/game", () => ({
17+
Game: {
18+
getWorldName: () => "pandora",
19+
},
20+
}));
21+
22+
const createStoredNotification = (
23+
overrides?: Partial<StoredNotification>,
24+
): StoredNotification => ({
25+
notificationId: "notification-1",
26+
discordId: "discord-1",
27+
guildId: "guild-1",
28+
world: "pandora",
29+
createdAt: "2026-04-17T10:00:00.000Z",
30+
message: "Hej",
31+
servers: ["guild-1"],
32+
listKey: "notification-1",
33+
receivedAtMs: Date.now(),
34+
...overrides,
35+
});
36+
37+
describe("useVisibleNotifications", () => {
38+
beforeEach(() => {
39+
vi.useFakeTimers();
40+
vi.setSystemTime(new Date("2026-04-17T10:00:00.000Z"));
41+
mockUseCurrentGameAccountNotificationSettings.mockReset();
42+
mockUseCurrentGameAccountNotificationSettings.mockReturnValue({
43+
settings: {
44+
message: {
45+
show: true,
46+
ignoreOtherWorlds: false,
47+
guildIds: ["guild-1"],
48+
autoHideTimeout: 30,
49+
},
50+
},
51+
});
52+
useNotificationsStore.setState({
53+
notifications: [],
54+
notificationAutoHideByListKey: {},
55+
latestNotificationAnimationCycle: 0,
56+
});
57+
});
58+
59+
afterEach(() => {
60+
vi.useRealTimers();
61+
});
62+
63+
it("schedules cleanup with timeouts instead of interval polling", () => {
64+
const setIntervalSpy = vi.spyOn(window, "setInterval");
65+
66+
useNotificationsStore.setState({
67+
notifications: [
68+
createStoredNotification({
69+
notificationId: "notification-1",
70+
listKey: "notification-1",
71+
receivedAtMs: Date.now(),
72+
}),
73+
createStoredNotification({
74+
notificationId: "notification-2",
75+
listKey: "notification-2",
76+
receivedAtMs: Date.now(),
77+
}),
78+
],
79+
notificationAutoHideByListKey: {
80+
"notification-1": {
81+
deadlineMs: Date.now() + 1_000,
82+
pausedRemainingMs: null,
83+
durationMs: 1_000,
84+
},
85+
"notification-2": {
86+
deadlineMs: Date.now() + 2_000,
87+
pausedRemainingMs: null,
88+
durationMs: 2_000,
89+
},
90+
},
91+
});
92+
93+
renderHook(() => useVisibleNotifications({ autoCleanup: true }));
94+
95+
expect(setIntervalSpy).not.toHaveBeenCalled();
96+
97+
act(() => {
98+
vi.advanceTimersByTime(999);
99+
});
100+
101+
expect(useNotificationsStore.getState().notifications).toHaveLength(2);
102+
103+
act(() => {
104+
vi.advanceTimersByTime(1);
105+
});
106+
107+
expect(
108+
useNotificationsStore
109+
.getState()
110+
.notifications.map((notification) => notification.notificationId),
111+
).toEqual(["notification-2"]);
112+
113+
act(() => {
114+
vi.advanceTimersByTime(1_000);
115+
});
116+
117+
expect(useNotificationsStore.getState().notifications).toEqual([]);
118+
119+
setIntervalSpy.mockRestore();
120+
});
121+
122+
it("does not auto-remove paused notifications", () => {
123+
useNotificationsStore.setState({
124+
notifications: [
125+
createStoredNotification({
126+
notificationId: "notification-1",
127+
listKey: "notification-1",
128+
receivedAtMs: Date.now(),
129+
}),
130+
],
131+
notificationAutoHideByListKey: {
132+
"notification-1": {
133+
deadlineMs: null,
134+
pausedRemainingMs: 1_000,
135+
durationMs: 1_000,
136+
},
137+
},
138+
});
139+
140+
renderHook(() => useVisibleNotifications({ autoCleanup: true }));
141+
142+
act(() => {
143+
vi.advanceTimersByTime(5_000);
144+
});
145+
146+
expect(
147+
useNotificationsStore
148+
.getState()
149+
.notifications.map((notification) => notification.notificationId),
150+
).toEqual(["notification-1"]);
151+
});
152+
});

apps/game-client/src/features/notifications/hooks/use-visible-notifications.ts

Lines changed: 83 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,48 @@ const getExpirationTimeMs = (
3636
return notification.receivedAtMs + timeoutSeconds * 1000;
3737
};
3838

39+
const getScheduledExpirationTimeMs = ({
40+
notification,
41+
notificationAutoHideByListKey,
42+
settings,
43+
}: {
44+
notification: StoredNotification;
45+
notificationAutoHideByListKey: Record<
46+
string,
47+
{
48+
deadlineMs: number | null;
49+
pausedRemainingMs: number | null;
50+
durationMs: number;
51+
}
52+
>;
53+
settings: Record<string, { autoHideTimeout?: number }>;
54+
}) => {
55+
const key = getKey(notification);
56+
const notificationSettings = settings[key];
57+
58+
if (!notificationSettings?.autoHideTimeout) {
59+
return null;
60+
}
61+
62+
if (notificationSettings.autoHideTimeout <= 0) {
63+
return null;
64+
}
65+
66+
const autoHideState = notificationAutoHideByListKey[notification.listKey];
67+
68+
if (autoHideState?.pausedRemainingMs !== null) {
69+
return null;
70+
}
71+
72+
return (
73+
autoHideState?.deadlineMs ??
74+
getExpirationTimeMs(notification, notificationSettings.autoHideTimeout)
75+
);
76+
};
77+
3978
export const useVisibleNotifications = ({
4079
autoCleanup = true,
41-
tickMs = 1000,
80+
tickMs: _tickMs = 1000,
4281
}: UseVisibleNotificationsOptions = {}): UseVisibleNotificationsResult => {
4382
const { notifications, notificationAutoHideByListKey, removeNotification } =
4483
useNotificationsStore();
@@ -47,55 +86,53 @@ export const useVisibleNotifications = ({
4786
const removeRef = useRef(removeNotification);
4887
removeRef.current = removeNotification;
4988

50-
const needsTick = useMemo(() => {
51-
return notifications.some((n) => {
52-
const key = getKey(n) as keyof typeof settings;
53-
const s = settings[key];
54-
if (!s?.autoHideTimeout || s.autoHideTimeout <= 0) {
55-
return false;
56-
}
89+
useEffect(() => {
90+
if (!autoCleanup) {
91+
return;
92+
}
5793

58-
const autoHideState = notificationAutoHideByListKey[n.listKey];
94+
const scheduledExpirations = notifications
95+
.map((notification) => ({
96+
notification,
97+
expirationTimeMs: getScheduledExpirationTimeMs({
98+
notification,
99+
notificationAutoHideByListKey,
100+
settings,
101+
}),
102+
}))
103+
.filter(
104+
(
105+
entry,
106+
): entry is {
107+
notification: StoredNotification;
108+
expirationTimeMs: number;
109+
} => entry.expirationTimeMs !== null,
110+
);
59111

60-
if (!autoHideState) {
61-
return true;
62-
}
112+
if (scheduledExpirations.length === 0) {
113+
return;
114+
}
63115

64-
return autoHideState.deadlineMs !== null;
65-
});
66-
}, [notificationAutoHideByListKey, notifications, settings]);
116+
const nearestExpirationTimeMs = Math.min(
117+
...scheduledExpirations.map((entry) => entry.expirationTimeMs),
118+
);
119+
const timeoutId = window.setTimeout(
120+
() => {
121+
const currentTimeMs = Date.now();
67122

68-
useEffect(() => {
69-
if (!autoCleanup || !needsTick) return;
70-
71-
const id = window.setInterval(() => {
72-
const currentTimeMs = Date.now();
73-
74-
notifications.forEach((n) => {
75-
const key = getKey(n) as keyof typeof settings;
76-
const s = settings[key];
77-
if (!s?.autoHideTimeout) return;
78-
if (s.autoHideTimeout <= 0) return;
79-
const autoHideState = notificationAutoHideByListKey[n.listKey];
80-
if (autoHideState?.pausedRemainingMs !== null) return;
81-
const expirationTimeMs =
82-
autoHideState?.deadlineMs ??
83-
getExpirationTimeMs(n, s.autoHideTimeout);
84-
if (expirationTimeMs !== null && currentTimeMs >= expirationTimeMs) {
85-
removeRef.current(n.notificationId);
86-
}
87-
});
88-
}, tickMs);
89-
90-
return () => clearInterval(id);
91-
}, [
92-
autoCleanup,
93-
needsTick,
94-
notificationAutoHideByListKey,
95-
notifications,
96-
settings,
97-
tickMs,
98-
]);
123+
scheduledExpirations.forEach(({ expirationTimeMs, notification }) => {
124+
if (currentTimeMs >= expirationTimeMs) {
125+
removeRef.current(notification.notificationId);
126+
}
127+
});
128+
},
129+
Math.max(0, nearestExpirationTimeMs - Date.now()),
130+
);
131+
132+
return () => {
133+
window.clearTimeout(timeoutId);
134+
};
135+
}, [autoCleanup, notificationAutoHideByListKey, notifications, settings]);
99136

100137
const visible = useMemo(() => {
101138
return notifications.filter((n) => {

apps/game-client/src/features/notifications/notifications.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ export const Notifications = () => {
3131
);
3232
const { notifications: filteredNotifications } = useVisibleNotifications({
3333
autoCleanup: true,
34-
tickMs: 100,
3534
});
3635

3736
useEffect(() => {

apps/game-client/src/processors/npcs-delete-processor.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ describe("NpcsDeleteProcessor", () => {
106106
npc: { id: 500 },
107107
} as never,
108108
],
109-
activeNotificationAnimations: { "notification-1": 1 },
110109
notificationAutoHideByListKey: {
111110
"notification-1": {
112111
deadlineMs: null,

0 commit comments

Comments
 (0)