revert(api): drop the /api/user/settings notif+chat-unread fan-out (SSR 9s-timeout regression) (#2699)

* Revert "perf(api): SSR-seed chat.getUnreadCount to cut ~19 req/s off api-primary (#2691)"

This reverts commit 996d5982b3.

* Revert "perf(api): SSR-inject user.checkNotifications to cut ~21 req/s off api-primary (#2690)"

This reverts commit 86eab5d1d2.
This commit is contained in:
Zachary Lowden
2026-06-22 10:40:14 -05:00
committed by GitHub
parent e237357538
commit b751ebcd3e
10 changed files with 50 additions and 365 deletions
@@ -9,7 +9,6 @@ import { useSignalConnection } from '~/components/Signals/SignalsProvider';
import { useCurrentUser } from '~/hooks/useCurrentUser';
import { NotificationCategory, SignalMessages } from '~/server/common/enums';
import type { GetUserNotificationsSchema } from '~/server/schema/notification.schema';
import type { UserNotificationCounts } from '~/server/services/notification.service';
import type { NotificationGetAll, NotificationGetAllItem } from '~/types/router';
import { getDisplayName } from '~/utils/string-helpers';
import { trpc } from '~/utils/trpc';
@@ -137,10 +136,7 @@ export const useMarkReadNotification = () => {
if (newCounts[key] < 0) newCounts[key] = 0;
}
// newCounts is built from `...old` (UserNotificationCounts) plus a guaranteed
// `all`, then only mutated numerically — it satisfies the count shape. The
// `Record<string, number>` buffer above is only to allow dynamic-key indexing.
return newCounts as UserNotificationCounts;
return newCounts;
});
// Mark as read in notification feed
@@ -217,8 +213,7 @@ export const useNotificationSignal = () => {
(newCounts[updated.category.toLowerCase()] ?? 0) + 1;
newCounts['all']++;
// See the mark-read updater: numeric buffer that satisfies the count shape.
return newCounts as UserNotificationCounts;
return newCounts;
});
},
[queryClient, queryUtils]
+1 -43
View File
@@ -55,14 +55,11 @@ import { IsClientProvider } from '~/providers/IsClientProvider';
// import { StripeSetupSuccessProvider } from '~/providers/StripeProvider';
import { ThemeProvider } from '~/providers/ThemeProvider';
import type { UserContentSettings } from '~/server/schema/user.schema';
import type { RouterOutput } from '~/types/router';
type ChatUnreadCount = RouterOutput['chat']['getUnreadCount'];
import type { UserSettingsChat } from '~/server/schema/chat.schema';
import { resolveChatSettings } from '~/server/schema/chat.schema';
import type { FeatureAccess } from '~/server/services/feature-flags.service';
import type { TosMeta } from '~/server/services/content.service';
import type { AnnouncementsSeed } from '~/providers/announcements-seed';
import type { UserNotificationCounts } from '~/server/services/notification.service';
import type { BrowsingSettingsAddon } from '~/shared/constants/browsing-settings-addons';
import type { ParsedCookies } from '~/shared/utils/cookies';
import { parseCookies } from '~/shared/utils/cookies';
@@ -110,15 +107,10 @@ type CustomAppProps = {
tosMeta?: TosMeta;
announcements?: AnnouncementsSeed;
following?: number[];
notificationCounts?: UserNotificationCounts;
seed: number;
settings: UserContentSettings;
browsingSettingsAddons: BrowsingSettingsAddon[];
liveNow: boolean;
// SSR-seeded `chat.getUnreadCount` (logged-in only). The per-chat unread tallies
// behind the header chat badge. Optional: absent for anon and on the fail-open
// path (the client query self-heals on its own fetch). See AppProvider seed.
chatUnreadCount?: ChatUnreadCount;
// SSR-seeded `chat.getUserSettings` (logged-in only) — the per-user chat
// settings (mute sounds / bad-word filter / acknowledged). Derived from the
// `settings.chat` field already fetched for the bootstrap; no extra I/O. See
@@ -146,14 +138,12 @@ function MyApp(props: CustomAppProps) {
tosMeta,
announcements,
following,
notificationCounts,
seed = Date.now(),
canIndex,
hasAuthCookie,
settings,
browsingSettingsAddons,
liveNow = false,
chatUnreadCount,
chatSettings,
region,
domain,
@@ -207,9 +197,7 @@ function MyApp(props: CustomAppProps) {
tosMeta={tosMeta}
announcements={announcements}
following={following}
notificationCounts={notificationCounts}
liveNow={liveNow}
chatUnreadCount={chatUnreadCount}
chatSettings={chatSettings}
region={region}
domain={domain}
@@ -403,8 +391,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
tosMeta?: TosMeta;
announcements?: AnnouncementsSeed;
following?: number[];
chatUnreadCount?: ChatUnreadCount;
notificationCounts?: UserNotificationCounts;
session: Session | null;
};
let settingsBootstrap: SettingsBootstrap;
@@ -457,20 +443,10 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
tosMeta: undefined,
announcements: undefined,
following: undefined,
chatUnreadCount: undefined,
notificationCounts: undefined,
session: null,
};
}
const {
settings,
session,
tosMeta,
announcements,
following,
chatUnreadCount,
notificationCounts,
} = settingsBootstrap;
const { settings, session, tosMeta, announcements, following } = settingsBootstrap;
// Pass these via the request so we can use them in SSR. Resolve the per-user
// feature flags and the global (redis-cached, identical-for-all-users) browsing
// setting addons in PARALLEL — neither depends on the other and both sit on
@@ -513,22 +489,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
const domain = getRequestDomainColor(request);
// NOTE: `chat.getUnreadCount` (the always-visible header chat badge, ~19 req/s
// off api-primary) is SSR-seeded too, but it is computed in the server-only
// `/api/user/settings` route above and delivered via that fetch (read off
// `settingsBootstrap` as `chatUnreadCount`). It is deliberately NOT resolved
// here: `chat.service` is server-only and importing it into this graph — even
// via a dynamic `await import` — pulls Node built-ins (`node:fs`/
// `node:perf_hooks`) into `_app`'s client bundle and breaks `next build` (same
// class as the `content.service`/`announcement.service` carve-outs above and
// the `prom-client` note below). It rides down through pageProps to AppProvider
// and seeds the ambient query there. It CANNOT be deferred to chat-open: the
// badge is always visible, and the live-increment path in ChatSignals.ts only
// bumps an EXISTING cache entry (`produce((old) => if (!old) return old)`) — an
// unseeded badge would silently drop every incoming-message bump until the user
// opened chat. Fail open to `undefined` (the client query self-heals on its own
// fetch) — a DB blip in the route must never 500 a page render.
// SSR-inject two ambient per-bootstrap trpc results that fire on every
// logged-in page load but are fully derivable from data already fetched here.
// Both client queries gate on a logged-in user, so only seed for sessions.
@@ -597,8 +557,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
tosMeta,
announcements,
following,
chatUnreadCount,
notificationCounts,
seed: Date.now(),
hasAuthCookie,
region,
+1 -32
View File
@@ -4,8 +4,6 @@ import { getServerAuthSession } from '~/server/auth/get-server-auth-session';
import { getTosMeta } from '~/server/services/content.service';
import { getCurrentAnnouncements } from '~/server/services/announcement.service';
import { getUserFollows } from '~/server/redis/caches';
import { getUnreadMessagesForUser } from '~/server/services/chat.service';
import { getUserNotificationCounts } from '~/server/services/notification.service';
import { getRequestDomainColor } from '~/server/utils/server-domain';
export default PublicEndpoint(
@@ -21,8 +19,7 @@ export default PublicEndpoint(
// and drop the critical settings/session payload; tosMeta (static, no user
// input) can still throw to the outer catch (preserving prior behaviour).
const domainColor = getRequestDomainColor(req);
const [tosMeta, announcements, following, chatUnreadCount, notificationCounts] =
await Promise.all([
const [tosMeta, announcements, following] = await Promise.all([
// Resolve the static per-domain ToS metadata here (server-only API route)
// so `_app` getInitialProps can deliver it WITHOUT importing
// `content.service` — which pulls `fs/promises` into `_app`'s client-bundled
@@ -50,40 +47,12 @@ export default PublicEndpoint(
session?.user
? getUserFollows(session.user.id).catch(() => undefined)
: Promise.resolve(undefined),
// SSR-seed the ambient, auth-gated `chat.getUnreadCount` query (~19 req/s
// on api-primary; the always-visible header chat badge). Computed here —
// NOT in `_app` getInitialProps — because `chat.service` is server-only
// and importing it into `_app` (even via a dynamic `await import`) pulls
// Node built-ins (`node:fs`/`node:perf_hooks`, via the `env/server` +
// `errorHandling` + `unfurl.js` import chain) into the client bundle and
// breaks `next build`. `getUnreadMessagesForUser` is the same fn the
// resolver runs, so the seed is byte-identical (`{ chatId, cnt }[]`, #2471
// gotcha). Anon never fires this protected query, so seed authed-only; on
// a chat-service error fall back to undefined and let the client self-heal
// via `ChatButton`'s own live fetch.
session?.user
? getUnreadMessagesForUser({ userId: session.user.id }).catch(() => undefined)
: Promise.resolve(undefined),
// SSR-seed the ambient, auth-gated `user.checkNotifications` query (the
// header bell unread count, fires on every logged-in bootstrap). Uses
// the SAME shared `getUserNotificationCounts` reduce the resolver uses,
// so the seed is byte-identical to a live fetch (plain { all, <cat> }
// object of numbers — no superjson Date/array divergence). Anon never
// fires this query (protectedProcedure + `enabled: !!currentUser`), so
// seed authed-only. `.catch(undefined)` so a redis/DB blip degrades to
// no-seed (client self-heals on bootstrap) and can never reject the
// Promise.all and drop the critical settings/session payload.
session?.user
? getUserNotificationCounts({ userId: session.user.id }).catch(() => undefined)
: Promise.resolve(undefined),
]);
res.status(200).json({
settings,
tosMeta,
announcements,
following,
chatUnreadCount,
notificationCounts,
session: session?.user && Object.keys(session.user).length > 0 ? session : null,
});
} catch (e) {
-52
View File
@@ -9,10 +9,6 @@ import { setServerDomains } from '~/utils/sync-account';
import { trpc } from '~/utils/trpc';
import type { AnnouncementsSeed } from '~/providers/announcements-seed';
import { reviveAnnouncementsSeed } from '~/providers/announcements-seed';
import type { RouterOutput } from '~/types/router';
type ChatUnreadCount = RouterOutput['chat']['getUnreadCount'];
import type { UserNotificationCounts } from '~/server/services/notification.service';
type AppProviderProps = {
children: React.ReactNode;
@@ -30,24 +26,11 @@ type AppProviderProps = {
// followed userIds. Seeds the query directly (fixed `undefined` key) so the
// ambient follow/notify buttons never fire it on bootstrap.
following?: number[];
// SSR-computed `user.checkNotifications` result (logged-in only) — the header
// bell unread count, reduced to { all, <category>: count }. Seeds the ambient
// `useQueryNotificationsCount` query (fixed `undefined` key) so it never fires
// on bootstrap. It's a LIVE count: the existing freshness path (the
// `NotificationNew` signal + mark-read optimistic `setData`) applies ON TOP of
// the seed, so it stays current without a refetch — the seed only replaces the
// one-shot bootstrap fetch.
notificationCounts?: UserNotificationCounts;
// SSR-computed `system.getLiveNow` global boolean (a single redis.get,
// identical for every user). Seeds the ambient `useIsLive` query (fixed
// `undefined` key) so it never fires on bootstrap. Public procedure → seeded
// for everyone, no auth gate.
liveNow: boolean;
// SSR-computed `chat.getUnreadCount` (logged-in only) — the per-chat unread
// tallies behind the header chat badge. Seeds the ambient query (fixed
// `undefined` key) so the badge never fires it on bootstrap. Absent for anon
// and on the fail-open path (the consumers' own live fetch takes over).
chatUnreadCount?: ChatUnreadCount;
// SSR-computed `chat.getUserSettings` (logged-in only) — the per-user chat
// settings (mute sounds / bad-word filter / acknowledged). Seeds the ambient
// query (fixed `undefined` key) so the chat widget never fires it on
@@ -108,9 +91,7 @@ export function AppProvider({
tosMeta,
announcements,
following,
notificationCounts,
liveNow,
chatUnreadCount,
chatSettings,
domain,
host,
@@ -136,25 +117,6 @@ export function AppProvider({
initialData: following,
enabled: !!following,
});
// Seed `user.checkNotifications` (the header bell unread count) from the SSR
// snapshot so `useQueryNotificationsCount` reads a primed cache and never
// fires the query on bootstrap (~21 req/s off api-primary). Shares the fixed
// `undefined` query key with that hook. It IS a live count, but freshness
// does NOT come from a poll — it comes from the `NotificationNew` signal +
// mark-read optimistic `setData`, both of which apply on top of whatever is in
// the cache (seed or fetched). The consumer hook sets `staleTime: Infinity`
// (no time-based refetch today either), so seeding here is behavior-identical:
// it replaces the single bootstrap fetch and the count self-corrects via the
// same signal/mutation path as before. Match that `staleTime: Infinity` so the
// seed counts as fresh and no self-heal fetch fires. `enabled: !!notificationCounts`
// skips the seed (and any fetch from this provider) when there's no snapshot
// (anon never fires this protectedProcedure; a failed authed snapshot falls
// back to the consumer's own live bootstrap fetch).
trpc.user.checkNotifications.useQuery(undefined, {
initialData: notificationCounts,
enabled: !!notificationCounts,
staleTime: Infinity,
});
// Seed the global `system.getLiveNow` boolean from the SSR snapshot so the
// ambient `useIsLive` consumers (header logo, social links, social home
// block) read a primed cache and never fire the query on bootstrap. Shares
@@ -166,20 +128,6 @@ export function AppProvider({
initialData: liveNow,
staleTime: 1000 * 60 * 5,
});
// Seed `chat.getUnreadCount` (the header chat badge) from the SSR snapshot so
// the badge reads a primed cache and never fires the query on bootstrap
// (~19 req/s off api-primary). Shares the fixed `undefined` query key with
// `ChatButton`'s `trpc.chat.getUnreadCount.useQuery`. Updates stay LIVE via
// `ChatSignals`' `setData` on incoming messages (same model as
// `getFollowingUsers` above — no external churn to poll for), so the global
// `staleTime: Infinity` default is correct. `enabled: !!chatUnreadCount` skips
// the seed (and any self-heal fetch) only when there's no snapshot: anon never
// fires this protected query, and a failed authed snapshot falls back to
// `ChatButton`'s own live fetch.
trpc.chat.getUnreadCount.useQuery(undefined, {
initialData: chatUnreadCount,
enabled: !!chatUnreadCount,
});
// Seed `chat.getUserSettings` (per-user chat settings) from the SSR snapshot
// so the chat widget reads a primed cache and never fires the query on
// bootstrap (~19 req/s off api-primary). Shares the fixed `undefined` query
+29 -7
View File
@@ -21,12 +21,7 @@ import type {
import { resolveChatSettings } from '~/server/schema/chat.schema';
import { latestChat, singleChatSelect } from '~/server/selectors/chat.selector';
import { profileImageSelect } from '~/server/selectors/image.selector';
import {
createMessage,
getUnreadMessagesForUser,
maxUsersPerChat,
upsertChat,
} from '~/server/services/chat.service';
import { createMessage, maxUsersPerChat, upsertChat } from '~/server/services/chat.service';
import { getUserSettings, setUserSetting } from '~/server/services/user.service';
import { withSignals } from '~/server/signals/wrapper';
import {
@@ -116,7 +111,34 @@ export const getUnreadMessagesForUserHandler = async ({
}) => {
try {
const { id: userId } = ctx.user;
return await getUnreadMessagesForUser({ userId });
const unread = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>`
select memb."chatId" as "chatId",
count(msg.id)::integer as "cnt"
from "ChatMember" memb
left join "ChatMessage" msg
on msg."chatId" = memb."chatId" and
(msg.id > memb."lastViewedMessageId" or
memb."lastViewedMessageId" is null
)
where memb."userId" = ${userId}
and memb.status = 'Joined'
and memb."isMuted" is false
and msg."userId" != ${userId}
group by memb."chatId"
`;
const pending = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>`
select memb."chatId" as "chatId",
1 as "cnt"
from "ChatMember" memb
where memb."userId" = ${userId}
and memb.status = 'Invited'
and memb."isMuted" is false
group by memb."chatId"
`;
return [...unread, ...pending];
} catch (error) {
if (error instanceof TRPCError) throw error;
else throw throwDbError(error);
+17 -4
View File
@@ -5,6 +5,7 @@ import { env } from '~/env/server';
import { clickhouse } from '~/server/clickhouse/client';
import { purgeCache } from '~/server/cloudflare/client';
import { constants } from '~/server/common/constants';
import type { NotificationCategory } from '~/server/common/enums';
import {
OnboardingComplete,
OnboardingSteps,
@@ -52,7 +53,7 @@ import type {
WithClaimKey,
} from '~/server/selectors/cosmetic.selector';
import { simpleUserSelect } from '~/server/selectors/user.selector';
import { getUserNotificationCounts } from '~/server/services/notification.service';
import { getUserNotificationCount } from '~/server/services/notification.service';
import {
getResourceReviewsByUserId,
getUserResourceReview,
@@ -252,9 +253,21 @@ export const checkUserNotificationsHandler = async ({ ctx }: { ctx: ProtectedCon
const { id } = ctx.user;
try {
// Shared reduce so the SSR-seed path (`/api/user/settings`) returns a
// byte-identical object to this resolver — see getUserNotificationCounts.
return await getUserNotificationCounts({ userId: id });
const unreadCount = await getUserNotificationCount({
userId: id,
unread: true,
});
const reduced = unreadCount.reduce(
(acc, { category, count }) => {
const key = category.toLowerCase() as Lowercase<NotificationCategory>;
acc[key] = Number(count);
acc['all'] += Number(count);
return acc;
},
{ all: 0 } as Record<Lowercase<NotificationCategory> | 'all', number>
);
return reduced;
} catch (error) {
if (error instanceof TRPCError) throw error;
else throw throwDbError(error);
@@ -1,78 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// `getUnreadMessagesForUser` is the shared source for both the `chat.getUnreadCount`
// tRPC resolver AND the new `_app` SSR bootstrap seed. The seed value must be
// byte-identical to the resolver output (#2471 gotcha) or the primed client
// cache would mismatch and force the very bootstrap refetch we're cutting.
// We mock only the two DB reads and exercise the concat/shape contract.
const h = vi.hoisted(() => ({
queryRaw: vi.fn(),
}));
vi.mock('~/server/db/client', () => ({
dbRead: { $queryRaw: h.queryRaw },
dbWrite: {},
}));
// chat.service transitively imports `Prisma.validator<...>()(...)` selector
// files at module-eval; `Prisma.validator` isn't present in the SSR test
// transform of `@prisma/client`, so provide a pass-through (mirrors
// model3d-visible-id-for-post.test.ts). Unblocks the import chain without
// faking any logic this test exercises.
vi.mock('@prisma/client', () => ({
Prisma: {
validator: () => (x: unknown) => x,
sql: () => ({}),
join: () => ({}),
raw: () => ({}),
SortOrder: { asc: 'asc', desc: 'desc' },
},
}));
vi.mock('unfurl.js', () => ({ unfurl: vi.fn() }));
vi.mock('linkifyjs', () => ({ find: vi.fn(() => []) }));
vi.mock('~/server/signals/wrapper', () => ({ withSignals: vi.fn() }));
vi.mock('~/server/services/blocklist.service', () => ({
throwOnBlockedLinkDomain: vi.fn(),
throwOnBlockedMessagePattern: vi.fn(),
}));
// Cut the heavy transitive import graph chat.service drags in at module-eval:
// `user.service` reaches `image.service` -> `event-engine-common/feeds` (a
// submodule outside src, unresolvable in the test transform), and
// `user-preferences.service` is unrelated to the unread-count query. Neither is
// used by `getUnreadMessagesForUser`, so stub them at the boundary.
vi.mock('~/server/services/user.service', () => ({ getUserSettings: vi.fn() }));
vi.mock('~/server/services/user-preferences.service', () => ({
BlockedByUsers: { getCached: vi.fn() },
BlockedUsers: { getCached: vi.fn() },
}));
const { getUnreadMessagesForUser } = await import('~/server/services/chat.service');
describe('getUnreadMessagesForUser (chat.getUnreadCount SSR-seed source)', () => {
beforeEach(() => h.queryRaw.mockReset());
it('returns the joined unread tallies concatenated with pending invites', async () => {
// First call = unread (Joined), second = pending (Invited).
h.queryRaw
.mockResolvedValueOnce([{ chatId: 1, cnt: 3 }])
.mockResolvedValueOnce([{ chatId: 9, cnt: 1 }]);
const result = await getUnreadMessagesForUser({ userId: 42 });
expect(h.queryRaw).toHaveBeenCalledTimes(2);
// Byte-equality contract: the seed array shape == the resolver output —
// `{ chatId, cnt }[]`, unread first then pending. A regression in either
// query's ORDER or SHAPE breaks here.
expect(result).toEqual([
{ chatId: 1, cnt: 3 },
{ chatId: 9, cnt: 1 },
]);
});
it('returns an empty array when the user has no unread or pending chats', async () => {
h.queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const result = await getUnreadMessagesForUser({ userId: 7 });
expect(result).toEqual([]);
});
});
-38
View File
@@ -39,44 +39,6 @@ const messageLimiter = createLimiter({
refetchInterval: 60 * 60, // 1 hour window
});
/**
* Per-chat unread tallies for a user (the header chat badge source).
*
* Extracted from the `chat.getUnreadCount` controller so the SSR bootstrap seed
* (`_app.getInitialProps`) and the tRPC resolver share ONE query the seed must
* be byte-identical to the resolver output (#2471 gotcha) or the primed cache
* would mismatch and force an immediate refetch.
*/
export async function getUnreadMessagesForUser({ userId }: { userId: number }) {
const unread = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>`
select memb."chatId" as "chatId",
count(msg.id)::integer as "cnt"
from "ChatMember" memb
left join "ChatMessage" msg
on msg."chatId" = memb."chatId" and
(msg.id > memb."lastViewedMessageId" or
memb."lastViewedMessageId" is null
)
where memb."userId" = ${userId}
and memb.status = 'Joined'
and memb."isMuted" is false
and msg."userId" != ${userId}
group by memb."chatId"
`;
const pending = await dbRead.$queryRaw<{ chatId: number; cnt: number }[]>`
select memb."chatId" as "chatId",
1 as "cnt"
from "ChatMember" memb
where memb."userId" = ${userId}
and memb.status = 'Invited'
and memb."isMuted" is false
group by memb."chatId"
`;
return [...unread, ...pending];
}
export const upsertChat = async ({
userIds,
isModerator,
@@ -178,31 +178,6 @@ export async function getUserNotificationCount({
return result;
}
// The reduced { all, <category>: count } shape consumed by the header bell.
// Extracted so BOTH `user.checkNotifications` (the tRPC resolver) and the
// `_app` SSR-seed path (`/api/user/settings`) produce a byte-identical object —
// the seed must equal a live fetch or the header count would render wrong on
// first paint. Single source of truth for the reduce + key-casing.
export type UserNotificationCounts = Record<Lowercase<NotificationCategory> | 'all', number>;
export async function getUserNotificationCounts({
userId,
}: {
userId: number;
}): Promise<UserNotificationCounts> {
const unreadCount = await getUserNotificationCount({ userId, unread: true });
return unreadCount.reduce(
(acc, { category, count }) => {
const key = category.toLowerCase() as Lowercase<NotificationCategory>;
acc[key] = Number(count);
acc['all'] += Number(count);
return acc;
},
{ all: 0 } as UserNotificationCounts
);
}
// Per-user serialization queue. Rapid-click streams previously fanned out to
// N concurrent pool.connect() acquisitions against the notif pool, which
// starved it under load (46k "Connection terminated due to connection timeout"
-79
View File
@@ -44,7 +44,6 @@ const TOS_PROCEDURE = 'content.checkTosUpdate';
const ANNOUNCEMENTS_PROCEDURE = 'announcement.getAnnouncements';
const FOLLOWING_PROCEDURE = 'user.getFollowingUsers';
const LIVE_NOW_PROCEDURE = 'system.getLiveNow';
const CHECK_NOTIFICATIONS_PROCEDURE = 'user.checkNotifications';
// A core page a gate-passing user lands on directly (no /login bounce). Both
// procedures fire on every logged-in bootstrap regardless of which page, so
@@ -472,84 +471,6 @@ test.describe('SSR-injected getLiveNow (anonymous)', () => {
});
});
/**
* Regression guard for the SSR-inject of `user.checkNotifications` (~21/s on
* api-primary) the header notification-bell unread count. It is a
* `protectedProcedure` consumed by the ambient `useQueryNotificationsCount`
* hook, so it fires on every LOGGED-IN bootstrap (never anon no anon block,
* same as getFollowingUsers). SSR-computed in the `/api/user/settings`
* self-fetch that `_app` already makes (so NO server-only module is pulled into
* `_app`'s client bundle) via the SAME shared `getUserNotificationCounts` reduce
* the resolver uses, then seeded in AppProvider on the fixed `undefined` key.
*
* Byte-equality: the payload is a plain `{ all, <category>: count }` object of
* numbers no Date/array/`undefined` field, so the JSON seed and the live
* `result.data.json` compare directly with no superjson `undefined``null`
* divergence. (`buzz` is `0` not absent when there are no buzz notifications:
* the resolver only emits categories the SQL GROUP BY returns, so an absent
* category is absent on BOTH the seed and the live fetch they still `toEqual`.)
*
* FRESHNESS (the live-count requirement): this seed REPLACES the one-shot
* bootstrap fetch only it does NOT make the count stickier. The consumer hook
* already uses `staleTime: Infinity` (no time-poll today either); the count is
* kept live by the `NotificationNew` SignalR push + the mark-read optimistic
* `setData`, both of which apply on top of the cache (seed or fetched) exactly
* as before. So "seed-then-(signal-driven)-refresh" is preserved.
*/
test.describe('SSR-injected checkNotifications (logged-in)', () => {
test.use({ storageState: storageStatePath('mod') });
test('checkNotifications is seeded into __NEXT_DATA__ and not fetched on bootstrap', async ({
page,
}) => {
const trpcUrls = await collectTrpcRequests(page, AUTHED_LANDING);
const counts = await readPageProp(page, 'notificationCounts');
expect(counts.present, 'notificationCounts present in __NEXT_DATA__ pageProps').toBe(true);
expect(
counts.value && typeof counts.value === 'object' && !Array.isArray(counts.value),
'notificationCounts seed is a plain object'
).toBe(true);
// The reduced shape always carries an `all` total (the reduce seeds it to 0).
expect(typeof (counts.value as Record<string, unknown>)?.all, 'seed.all is a number').toBe(
'number'
);
const requests = trpcUrls.filter((u) => u.includes(CHECK_NOTIFICATIONS_PROCEDURE));
expect(
requests,
`no ${CHECK_NOTIFICATIONS_PROCEDURE} tRPC request should fire on bootstrap (it is SSR-injected); saw:\n${requests.join(
'\n'
)}`
).toHaveLength(0);
});
test('SSR seed byte-equals a live fetch', async ({ page }) => {
await page.goto(AUTHED_LANDING, { waitUntil: 'domcontentloaded' });
const seed = await readPageProp(page, 'notificationCounts');
expect(seed.present, 'notificationCounts seed present in __NEXT_DATA__').toBe(true);
const live = await fetchTrpcQueryJson(page, CHECK_NOTIFICATIONS_PROCEDURE);
// The shared `getUserNotificationCounts` reduce backs both the seed and the
// resolver, so they must be deep-equal. A wrong initial count is worse than
// not seeding (the bell would render stale on first paint) — this is the gate.
expect(
seed.value,
'user.checkNotifications SSR seed must byte-equal a live resolver fetch'
).toEqual(live);
// Surface the totals so an all-zero run (no unread notifications) is visible
// rather than a silent trivial pass, and assert every value is a number.
const obj = (seed.value ?? {}) as Record<string, unknown>;
// eslint-disable-next-line no-console
console.log(`[ssr-inject] checkNotifications seed: ${JSON.stringify(obj)}`);
for (const [k, v] of Object.entries(obj)) {
expect(typeof v, `checkNotifications.${k} is a number`).toBe('number');
}
});
});
/**
* MANUAL CHECKLIST behaviours of this PR that are not auto-asserted above.
* (Auth IS supported by this harness, but these are timing/visual/state cases