mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 13:17:15 -05:00
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
import type { ShouldRevalidateFunctionArgs } from "react-router";
|
|
import { isRevalidation } from "~/utils/remix";
|
|
import type { RevalidateScope } from "./chat-types";
|
|
|
|
const BROADCAST_REVALIDATE_MAX_JITTER_MS = 1_500;
|
|
// past this a revalidation still counted as in flight is taken to be orphaned, not slow
|
|
const PENDING_REVALIDATION_STALE_MS = 30 * 1000;
|
|
|
|
let activeScope: RevalidateScope | null = null;
|
|
let pendingRevalidations = 0;
|
|
let oldestPendingStartedAt: number | null = null;
|
|
let revalidationGeneration = 0;
|
|
let scheduledBroadcast: { scope: RevalidateScope | null } | null = null;
|
|
let heldSubmissions = 0;
|
|
let deferredRevalidation: {
|
|
revalidate: () => Promise<void>;
|
|
scope: RevalidateScope | null;
|
|
} | null = null;
|
|
|
|
/**
|
|
* Runs a fetcher submission, holding broadcast revalidations back until it has settled (its
|
|
* redirect followed). React Router drops a fetcher's redirect when a navigation started after
|
|
* the submission, and a revalidation is one: a broadcast landing mid-flight would leave the
|
|
* user on the page with nothing happening. The held revalidation runs once, afterwards.
|
|
*/
|
|
export async function holdRevalidationsDuring(submission: () => Promise<void>) {
|
|
heldSubmissions++;
|
|
try {
|
|
await submission();
|
|
} finally {
|
|
heldSubmissions--;
|
|
if (heldSubmissions === 0 && deferredRevalidation) {
|
|
const { revalidate, scope } = deferredRevalidation;
|
|
deferredRevalidation = null;
|
|
revalidateWithScope(revalidate, scope ?? undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Runs a broadcast triggered revalidation, remembering its scope while in flight so `shouldRevalidate` can skip loaders the broadcast cannot have changed. */
|
|
export function revalidateWithScope(
|
|
revalidate: () => Promise<void>,
|
|
scope: RevalidateScope | undefined,
|
|
) {
|
|
if (heldSubmissions > 0) {
|
|
// like an absorbed broadcast, a deferred revalidation of a different scope widens to unscoped
|
|
const widens =
|
|
deferredRevalidation !== null &&
|
|
deferredRevalidation.scope !== (scope ?? null);
|
|
deferredRevalidation = {
|
|
revalidate,
|
|
scope: widens ? null : (scope ?? null),
|
|
};
|
|
return;
|
|
}
|
|
|
|
forgetStalePendingRevalidations();
|
|
|
|
if (!scope) {
|
|
activeScope = null;
|
|
} else if (pendingRevalidations === 0) {
|
|
// never narrow the scope of an unscoped revalidation already in flight
|
|
activeScope = scope;
|
|
}
|
|
|
|
const generation = revalidationGeneration;
|
|
if (pendingRevalidations === 0) {
|
|
oldestPendingStartedAt = Date.now();
|
|
}
|
|
pendingRevalidations++;
|
|
void revalidate().finally(() => {
|
|
if (generation !== revalidationGeneration) return;
|
|
|
|
pendingRevalidations--;
|
|
if (pendingRevalidations === 0) {
|
|
activeScope = null;
|
|
oldestPendingStartedAt = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Runs a broadcast triggered revalidation after a random delay so a topic's clients do not all
|
|
* refetch in the same instant (thundering herd on e.g. the SendouQ looking channel). A broadcast
|
|
* arriving while one is scheduled is absorbed into it, widening its scope as needed.
|
|
*/
|
|
export function scheduleBroadcastRevalidation(
|
|
revalidate: () => Promise<void>,
|
|
scope: RevalidateScope | undefined,
|
|
) {
|
|
if (scheduledBroadcast) {
|
|
if (scheduledBroadcast.scope !== (scope ?? null)) {
|
|
scheduledBroadcast.scope = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pending: { scope: RevalidateScope | null } = { scope: scope ?? null };
|
|
scheduledBroadcast = pending;
|
|
setTimeout(() => {
|
|
scheduledBroadcast = null;
|
|
revalidateWithScope(revalidate, pending.scope ?? undefined);
|
|
}, Math.random() * BROADCAST_REVALIDATE_MAX_JITTER_MS);
|
|
}
|
|
|
|
/**
|
|
* Whether the pending revalidation is scoped to match results (reported scores, pick/ban events).
|
|
* Loaders not derived from match data can return `false` from `shouldRevalidate` for these — the
|
|
* most frequent broadcast during a live tournament, one per reported game.
|
|
*/
|
|
export function isMatchResultsScopedRevalidation(
|
|
args: ShouldRevalidateFunctionArgs,
|
|
) {
|
|
return isRevalidation(args) && activeScope === "MATCH_RESULTS";
|
|
}
|
|
|
|
/**
|
|
* Forgets revalidations counted as in flight for implausibly long: a `revalidate()` interrupted
|
|
* by a navigation never settles and would leave `activeScope` stuck for the life of the tab.
|
|
* Only the stuck case is forgotten, so a genuine in-flight one keeps its scope.
|
|
*/
|
|
function forgetStalePendingRevalidations() {
|
|
const isStale =
|
|
oldestPendingStartedAt !== null &&
|
|
Date.now() - oldestPendingStartedAt >= PENDING_REVALIDATION_STALE_MS;
|
|
if (!isStale) return;
|
|
|
|
pendingRevalidations = 0;
|
|
oldestPendingStartedAt = null;
|
|
activeScope = null;
|
|
revalidationGeneration++;
|
|
}
|