refactor(query-keys): expand factory and migrate auth, notifications, about

Routes the registration-status, auth invalidation, notifications
preferences, and Settings about-section api-version reads through
the centralised queryKeys factory. Adds factory entries for the
remaining settings/admin surfaces a follow-up will migrate, and
extends the walker test to fail on any future bare-literal
queryKey in src/app/auth, src/app/notifications, and the
about-section. Also swaps the dashboard tile Suspense fallback
from null to a layout-stable card placeholder so a future RSC
hoist of any tile slot can't trigger CLS.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 17:13:57 +02:00
1 parent 59befdca73
commit 0bf07abd9d
7 files changed
+106 -14

No files matched your search

+2 -2
View File
@@ -85,7 +85,7 @@ export default function LoginPage() {
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push(getRedirectTarget());
} catch (err) {
const { key, params } = describePasskeyError(err);
@@ -114,7 +114,7 @@ export default function LoginPage() {
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push(getRedirectTarget());
} catch {
setError(t("auth.loginFailed"));
+2 -1
View File
@@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label";
import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { queryKeys } from "@/lib/query-keys";
export default function RegisterPage() {
const router = useRouter();
@@ -54,7 +55,7 @@ export default function RegisterPage() {
return;
}
await queryClient.invalidateQueries({ queryKey: ["auth"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/");
} catch {
setError(t("auth.registerFailed"));
+9 -9
View File
@@ -12,6 +12,7 @@ import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
import { queryKeys } from "@/lib/query-keys";
interface ChannelInfo {
id: string;
@@ -86,7 +87,7 @@ export default function NotificationsPage() {
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ["notifications", "preferences"],
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
if (!res.ok) throw new Error("Failed to load preferences");
@@ -112,12 +113,11 @@ export default function NotificationsPage() {
},
onMutate: async ({ channelId, eventType, enabled }) => {
await queryClient.cancelQueries({
queryKey: ["notifications", "preferences"],
queryKey: queryKeys.notificationsPreferences(),
});
const prev = queryClient.getQueryData<PreferencesData>([
"notifications",
"preferences",
]);
const prev = queryClient.getQueryData<PreferencesData>(
queryKeys.notificationsPreferences(),
);
if (prev) {
const existing = prev.preferences.find(
(p) => p.channelId === channelId && p.eventType === eventType,
@@ -130,7 +130,7 @@ export default function NotificationsPage() {
)
: [...prev.preferences, { channelId, eventType, enabled }];
queryClient.setQueryData<PreferencesData>(
["notifications", "preferences"],
queryKeys.notificationsPreferences(),
{ ...prev, preferences: newPreferences },
);
}
@@ -139,14 +139,14 @@ export default function NotificationsPage() {
onError: (_err, _vars, context) => {
if (context?.prev) {
queryClient.setQueryData(
["notifications", "preferences"],
queryKeys.notificationsPreferences(),
context.prev,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["notifications", "preferences"],
queryKey: queryKeys.notificationsPreferences(),
});
},
});
+19 -1
View File
@@ -1413,7 +1413,25 @@ export default function DashboardPage() {
* future RSC hoists of any tile slot won't need a
* second pass to add the fallback infrastructure.
*/}
<Suspense fallback={null}>{entry.node}</Suspense>
{/*
* v1.4.41 W-FRONTEND-FACTORY (UX M1) — swap the
* `null` fallback for a layout-stable placeholder
* that mirrors the trend-card chrome. The body
* stays synchronous today so the fallback rarely
* paints, but a future RSC hoist of any tile slot
* would otherwise leave the grid track empty and
* trigger CLS as the cell paints in.
*/}
<Suspense
fallback={
<div
aria-hidden="true"
className="bg-card border-border h-full w-full rounded-xl border p-4 md:p-6"
/>
}
>
{entry.node}
</Suspense>
</div>
))}
</div>
+2 -1
View File
@@ -14,6 +14,7 @@ import {
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface VersionPayload {
version: string;
@@ -117,7 +118,7 @@ export function AboutSection() {
const fmt = useFormatters();
const { data: version, isLoading } = useQuery({
queryKey: ["api", "version"],
queryKey: queryKeys.apiVersion(),
queryFn: async () => {
const res = await fetch("/api/version");
if (!res.ok) throw new Error("version-fetch-failed");
+25
View File
@@ -49,6 +49,25 @@ describe("queryKeys factory", () => {
expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]);
});
// v1.4.41 W-FRONTEND-FACTORY — pin the shapes that auth/login + the
// notifications surface migrated to so a future rename can't drift
// the cache layout silently.
it("exposes auth + notifications keys for the factory-migrated surfaces", () => {
expect(queryKeys.authRegistrationStatus()).toEqual([
"auth",
"registration-status",
]);
expect(queryKeys.notificationsPreferences()).toEqual([
"notifications",
"preferences",
]);
expect(queryKeys.notificationsStatus()).toEqual([
"notifications",
"status",
]);
expect(queryKeys.apiVersion()).toEqual(["api", "version"]);
});
it("packs chartData params into a stable tuple prefixed by chart-data", () => {
const key = queryKeys.chartData(
"WEIGHT",
@@ -175,6 +194,12 @@ describe("queryKey factory enforcement", () => {
join(repoRoot, "src", "components", "comparison"),
join(repoRoot, "src", "app", "page.tsx"),
join(repoRoot, "src", "hooks", "use-auth.ts"),
// v1.4.41 W-FRONTEND-FACTORY — auth, notifications, and the
// about-section migrated to the factory. Future waves extend
// this list as they migrate their own surface.
join(repoRoot, "src", "app", "auth"),
join(repoRoot, "src", "app", "notifications"),
join(repoRoot, "src", "components", "settings", "about-section.tsx"),
];
function collect(): string[] {
+47
View File
@@ -93,9 +93,56 @@ export const queryKeys = {
passkeys: () => ["passkeys"] as const,
notificationsPreferences: () => ["notifications", "preferences"] as const,
notificationsStatus: () => ["notifications", "status"] as const,
settingsGlobalServices: () => ["settings", "global-services"] as const,
settingsNtfy: () => ["settings", "ntfy"] as const,
settingsReminderThresholds: () =>
["settings", "reminder-thresholds"] as const,
/**
* v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain,
* insights settings, user provider preference) and the targets editor
* all read these endpoints; centralising the keys keeps invalidation
* symmetrical with the user-thresholds + auth surfaces.
*/
insightsSettings: () => ["insights", "settings"] as const,
insightsProviderChain: () => ["insights", "provider-chain"] as const,
insightsGlp1Timeline: (limit: number | string) =>
["insights", "glp1-timeline", limit] as const,
userAiProvider: () => ["user", "ai-provider"] as const,
userProfile: () => ["user", "profile"] as const,
apiVersion: () => ["api", "version"] as const,
publicVersion: () => ["public", "version"] as const,
researchMode: () => ["research-mode"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,
featureFlags: () => ["feature-flags"] as const,
coachPrefs: () => ["coach-prefs"] as const,
/**
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its
* own bare-literal `["admin", "<name>"]`. Routing through the factory
* lets a single rename change every consumer in lockstep.
*/
adminAiQuality: () => ["admin", "ai-quality"] as const,
adminAppLogs: (
traceId: string | undefined,
action: string | undefined,
level: string | undefined,
range: string | undefined,
) =>
["admin", "app-logs", traceId, action, level, range] as const,
adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const,
adminBackups: () => ["admin", "backups"] as const,
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
adminFeedback: (status: string) => ["admin", "feedback", status] as const,
adminFeedbackRoot: () => ["admin", "feedback"] as const,
adminHostMetrics: (window: string) =>
["admin", "host-metrics", window] as const,
adminAuditActions: () => ["admin", "audit-log", "actions"] as const,
adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const,
tokens: () => ["tokens"] as const,
telegram: () => ["telegram"] as const,