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
This commit is contained in:
6 files changed
+30
-21
No files matched your search
@@ -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 ?? "/"}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
@@ -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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -71,7 +70,7 @@ export default async function Login(props: LoginProps) {
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LoginContent searchParams={searchParams} />
|
||||
<LoginContent searchParams={props.searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
<html lang={locale} dir={direction} suppressHydrationWarning>
|
||||
<head>
|
||||
<ColorSchemeScript />
|
||||
<SearchEngineOptimization />
|
||||
<Suspense>
|
||||
<SearchEngineOptimization />
|
||||
</Suspense>
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<Suspense>
|
||||
<Suspense fallback={<Loader size="xl" className="absolute inset-0 m-auto" />}>
|
||||
<SessionScopedProviders>{props.children}</SessionScopedProviders>
|
||||
</Suspense>
|
||||
</body>
|
||||
|
||||
@@ -39,6 +39,11 @@ export const getCachedIntegrationData = async <T>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 <T>(
|
||||
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) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }))))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user