From fbb2eddd877a043e5450d9e22e6cc49d93a80f3a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 7 Aug 2026 20:53:36 +0200 Subject: [PATCH] fix: concurrent stale race, Redis expire leak, missing invalidation, UI Suspense - Fix integration data cache: set pending synchronously before creating fetch to prevent concurrent stale-revalidation from spawning duplicate upstream calls - Redis cache handler: skip persisting entries with expire=0 (dynamic entries should not be stored in Redis) - Add missing invalidation: user.changePassword, group.updateGroup, group.transferOwnership - Move SearchEngineOptimization inside Suspense in root layout - Add Suspense fallback loader to root layout - Move searchParams resolution inside Suspense in login page --- apps/nextjs/src/app/[locale]/auth/login/page.tsx | 13 ++++++------- apps/nextjs/src/app/[locale]/layout.tsx | 8 +++++--- packages/api/src/integration-data-cache.ts | 13 +++++++------ packages/api/src/router/group.ts | 9 +++++++++ packages/api/src/router/user.ts | 1 + packages/next-cache-handler/src/redis-handler.ts | 7 ++----- 6 files changed, 30 insertions(+), 21 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 528c68331..2ed2f7181 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -19,11 +19,12 @@ interface LoginProps { }>; } -async function LoginContent({ searchParams }: { searchParams: { callbackUrl?: string } }) { +async function LoginContent({ searchParams }: { searchParams: Promise<{ callbackUrl?: string }> }) { + const resolvedParams = await searchParams; const session = await auth(); if (session) { - redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); + redirect(sanitizeRedirectionUrl(resolvedParams.callbackUrl)); } const t = await getScopedI18n("user.page.login"); @@ -52,7 +53,7 @@ async function LoginContent({ searchParams }: { searchParams: { callbackUrl?: st providers={env.AUTH_PROVIDERS} oidcClientName={env.AUTH_OIDC_CLIENT_NAME} isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN} - callbackUrl={searchParams.callbackUrl ?? "/"} + callbackUrl={resolvedParams.callbackUrl ?? "/"} /> @@ -60,9 +61,7 @@ async function LoginContent({ searchParams }: { searchParams: { callbackUrl?: st ); } -export default async function Login(props: LoginProps) { - const searchParams = await props.searchParams; - +export default function Login(props: LoginProps) { return ( } > - + ); } diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index afb557ffb..148b87031 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Suspense } from "react"; import { Inter } from "next/font/google"; -import { ColorSchemeScript } from "@mantine/core"; +import { ColorSchemeScript, Loader } from "@mantine/core"; import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; @@ -70,11 +70,13 @@ export default async function Layout(props: { - + + + - + }> {props.children} diff --git a/packages/api/src/integration-data-cache.ts b/packages/api/src/integration-data-cache.ts index c8c090a1e..344746de9 100644 --- a/packages/api/src/integration-data-cache.ts +++ b/packages/api/src/integration-data-cache.ts @@ -39,6 +39,11 @@ export const getCachedIntegrationData = async ( } } + // Set pending synchronously BEFORE creating the fetch promise to prevent + // concurrent stale-revalidation from spawning duplicate upstream calls. + const placeholder = existing ?? { data: undefined as T, timestamp: 0 }; + if (!existing) cache.set(key, placeholder); + const pending = fetcher() .then((data) => { cache.set(key, { data, timestamp: Date.now() }); @@ -67,13 +72,9 @@ export const getCachedIntegrationData = async ( throw error; }); - if (existing) { - existing.pending = pending; - return existing.data; - } + placeholder.pending = pending; - cache.set(key, { data: undefined as T, timestamp: 0, pending }); - return pending; + return existing ? existing.data : pending; }; export const invalidateIntegrationDataCache = (integrationId: string) => { diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index aef26559e..7570a8ee0 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -232,6 +232,14 @@ export const groupRouter = createTRPCRouter({ name: input.name, }) .where(eq(groups.id, input.id)); + + const members = await ctx.db.query.groupMembers.findMany({ + columns: { userId: true }, + where: eq(groupMembers.groupId, input.id), + }); + for (const member of members) { + invalidateUserCache(member.userId); + } }), savePartialSettings: permissionRequiredProcedure .requiresPermission("admin") @@ -325,6 +333,7 @@ export const groupRouter = createTRPCRouter({ ownerId: input.userId, }) .where(eq(groups.id, input.groupId)); + invalidateUserCache(input.userId); }), deleteGroup: permissionRequiredProcedure .requiresPermission("admin") diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index e93519723..7a46a398a 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -553,6 +553,7 @@ export const userRouter = createTRPCRouter({ password: hashedPassword, }) .where(eq(users.id, input.userId)); + invalidateUserCache(input.userId); }), changeHomeBoards: protectedProcedure .input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() })))) diff --git a/packages/next-cache-handler/src/redis-handler.ts b/packages/next-cache-handler/src/redis-handler.ts index c1aa39afd..30415e153 100644 --- a/packages/next-cache-handler/src/redis-handler.ts +++ b/packages/next-cache-handler/src/redis-handler.ts @@ -113,12 +113,9 @@ export class RedisCacheHandler implements CacheHandler { }); const ttlMs = entry.expire * 1000; + if (ttlMs <= 0) return; // Dynamic entries (expire: 0) should not be persisted await this.redis.connect().catch(() => {}); - if (ttlMs > 0) { - await this.redis.set(this.entryKey(cacheKey), serialized, "PX", ttlMs); - } else { - await this.redis.set(this.entryKey(cacheKey), serialized); - } + await this.redis.set(this.entryKey(cacheKey), serialized, "PX", ttlMs); } catch { // Fail open } finally {