fix(insights): keep the assessment card polling while a stale text revalidates

The stale-while-revalidate path serves the last-good assessment on a cache
miss and returns a terminal payload, so the status hook stopped polling. The
worker warmed a fresh assessment out of band seconds later, but the open card
never picked it up — the stale text stuck until the page remounted.

Add a `revalidating` flag to the read-only miss outcome, set only when
last-good text is served AND a refresh was enqueued (false on the
suppressed-stub branch and when no prior text exists). Thread it through the
generic metric-status generator and the seven specialised generators onto the
wire, type it on the client, and make `nextStatusPollInterval` keep polling on
`preparing || revalidating`. The existing 12-attempt ceiling bounds both
signals, so a persistently failing generation still cannot poll forever.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:14 +02:00
1 parent 03925ddece
commit 3694efe6e8
15 files changed
+125 -10

No files matched your search

@@ -20,6 +20,9 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
// A terminal `no-provider` / settled payload never carries preparing,
// so it stops immediately regardless of attempt count.
expect(nextStatusPollInterval(false, 0)).toBe(false);
// A settled payload with neither preparing nor revalidating is terminal.
expect(nextStatusPollInterval(false, 0, false)).toBe(false);
expect(nextStatusPollInterval(undefined, 0, undefined)).toBe(false);
});
it("keeps polling while preparing and below the cap", () => {
@@ -42,4 +45,24 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
expect(interval).not.toBe(false);
expect(interval as number).toBeGreaterThan(0);
});
// v1.9.0 — stale-while-revalidate: a terminal payload that serves last-good
// text sets `revalidating` so the open card keeps polling until the
// freshly-warmed assessment lands, without a remount.
it("keeps polling on revalidating even when not preparing", () => {
expect(nextStatusPollInterval(false, 0, true)).toBeTypeOf("number");
expect(nextStatusPollInterval(undefined, 1, true)).toBeTypeOf("number");
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS - 1, true),
).toBeTypeOf("number");
});
it("honours the same attempt cap for revalidating", () => {
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS, true),
).toBe(false);
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true),
).toBe(false);
});
});
+24 -6
View File
@@ -31,6 +31,15 @@ export interface InsightStatusData {
* warms the cache. Absent / false means the payload is terminal.
*/
preparing?: boolean;
/**
* v1.9.0 — true when the route serves last-good (stale) text AND a fresh
* generation is in flight. The served payload would otherwise be terminal
* (`preparing` false), so the open card would stop polling and never pick
* up the warmed assessment until a remount. The hook keeps polling on
* `preparing || revalidating` (bounded by the same attempt ceiling) so the
* card upgrades to the fresh text in the same session.
*/
revalidating?: boolean;
}
/**
@@ -64,17 +73,24 @@ const STATUS_POLL_MS = 4_000;
export const STATUS_POLL_MAX_ATTEMPTS = 12;
/**
* Decide whether a `preparing` card should schedule its next poll.
* Pure and shared between the `useInsightStatus` hook and the inline
* medication-compliance query so both sites enforce the identical
* ceiling. Returns the interval in ms while polling is warranted, or
* `false` once the payload is terminal OR the attempt cap is reached.
* Decide whether a card should schedule its next poll. Pure and shared
* between the `useInsightStatus` hook and the inline medication-compliance
* query so both sites enforce the identical ceiling. Returns the interval
* in ms while polling is warranted, or `false` once the payload is terminal
* OR the attempt cap is reached.
*
* v1.9.0 — `revalidating` joins `preparing` as a "keep polling" signal: when
* the route serves last-good (stale) text it would otherwise be terminal and
* stop the poll, so the freshly-warmed assessment never reaches the open card
* until a remount. The attempt ceiling bounds both signals identically, so a
* persistently failing generation still cannot poll an open page forever.
*/
export function nextStatusPollInterval(
preparing: boolean | undefined,
dataUpdateCount: number,
revalidating?: boolean | undefined,
): number | false {
if (!preparing) return false;
if (!preparing && !revalidating) return false;
if (dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS) return false;
return STATUS_POLL_MS;
}
@@ -161,6 +177,7 @@ export function useInsightStatus(metric: InsightStatusMetric) {
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}
@@ -225,6 +242,7 @@ export function useInsightMetricStatus(
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}