mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(ssr): clear the budget timer once a raced read answers
The mood and dashboard prefetches and the sign-in location lookup raced their work against a bare setTimeout that was never cleared, leaving one live timer behind per render or sign-in after the work had already answered. A shared helper races the work and clears the timer.
This commit is contained in:
1 parent
dd76b434c4
commit
b4e3e22d43
5 files changed
+64
-17
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
type MoodAggregates,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
import type { MoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import { settleWithin } from "@/lib/async/settle-within";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -32,12 +33,7 @@ import InsightsMoodPageClient from "./page-client";
|
||||
const PREFETCH_BUDGET_MS = 1_500;
|
||||
|
||||
function withinBudget<T>(work: Promise<T>): Promise<T | null> {
|
||||
return Promise.race([
|
||||
work,
|
||||
new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), PREFETCH_BUDGET_MS),
|
||||
),
|
||||
]).catch(() => null);
|
||||
return settleWithin(work, PREFETCH_BUDGET_MS, null).catch(() => null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-6
@@ -13,6 +13,7 @@ import {
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { settleWithin } from "@/lib/async/settle-within";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -30,11 +31,6 @@ import DashboardPageClient from "./page-client";
|
||||
*/
|
||||
const DIGEST_SOFT_BUDGET_MS = 400;
|
||||
|
||||
/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */
|
||||
function softTimeout<T>(ms: number): Promise<T | undefined> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin RSC wrapper around the (client) dashboard page.
|
||||
*
|
||||
@@ -144,7 +140,7 @@ export default async function DashboardPage() {
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [digest, series] = await Promise.all([
|
||||
Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]),
|
||||
settleWithin(digestWork, DIGEST_SOFT_BUDGET_MS, undefined),
|
||||
seriesWork,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { settleWithin } from "../settle-within";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("settleWithin", () => {
|
||||
it("answers with the work when it lands inside the budget, and leaves no timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
await expect(
|
||||
settleWithin(Promise.resolve("done"), 1_500, null),
|
||||
).resolves.toBe("done");
|
||||
// A bare race left the budget timer pending after the work answered.
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("answers with the fallback once the budget runs out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = settleWithin(new Promise<string>(() => {}), 1_500, null);
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
await expect(pending).resolves.toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("passes a rejection inside the budget through, and clears the timer", async () => {
|
||||
vi.useFakeTimers();
|
||||
await expect(
|
||||
settleWithin(Promise.reject(new Error("boom")), 1_500, null),
|
||||
).rejects.toThrow("boom");
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Wait for `work` at most `ms`, then settle with `fallback` instead.
|
||||
*
|
||||
* The budget timer is cleared as soon as either side settles. A bare
|
||||
* `Promise.race` against a `setTimeout` left the timer running after the
|
||||
* work had long answered: one live timer per server render or per audited
|
||||
* sign-in, each holding its closure until it fired. `work` is not
|
||||
* cancelled; a rejection of `work` inside the budget rejects here too.
|
||||
*/
|
||||
export async function settleWithin<T, F>(
|
||||
work: Promise<T>,
|
||||
ms: number,
|
||||
fallback: F,
|
||||
): Promise<T | F> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const budget = new Promise<F>((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([work, budget]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { settleWithin } from "@/lib/async/settle-within";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { lookupIpGeo } from "@/lib/geo";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
@@ -80,11 +81,7 @@ export async function auditLog(
|
||||
// provider can't stall the audit write.
|
||||
if (opts.ipAddress && action.startsWith("auth.")) {
|
||||
const ipAddress = opts.ipAddress;
|
||||
const timeout = new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 3000),
|
||||
);
|
||||
|
||||
Promise.race([lookupIpGeo(ipAddress), timeout])
|
||||
settleWithin(lookupIpGeo(ipAddress), 3000, null)
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const { location, asn, carrier } = result;
|
||||
|
||||
Reference in new issue
Block a user