Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions web/components/Chart.lazy-hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { Chart } from '@/components/Chart';
import { FETCH_TIMEOUT_MS } from '@/lib/chart-format';
import { bundleQueue, hydrationQueue, resetPayloadCache } from '@/lib/chart-store';

vi.mock('@/lib/chart-js', () => ({
Expand Down Expand Up @@ -84,10 +85,14 @@ describe('PR-5.0.95 landing-page lazy hydration', () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
fetchCalls = [];
resetPayloadCache();
MockIO.instances = [];
vi.stubGlobal('IntersectionObserver', MockIO);
vi.stubGlobal('fetch', (url: string | URL) => {
fetchCalls.push(String(url));
if (String(url).includes('/api/group/')) {
return Promise.resolve({ ok: false, status: 404 } as Response);
}
return Promise.resolve(jsonResponse(windowedPayload(3572)));
});
container = document.createElement('div');
Expand Down Expand Up @@ -304,6 +309,9 @@ describe('PR-5.0.95 landing-page lazy hydration', () => {
const signals: AbortSignal[] = [];
vi.stubGlobal('fetch', (url: string | URL, init?: { signal?: AbortSignal }) => {
fetchCalls.push(String(url));
if (String(url).includes('/api/group/')) {
return Promise.resolve({ ok: false, status: 404 } as Response);
}
if (init?.signal) {
signals.push(init.signal);
}
Expand Down Expand Up @@ -441,6 +449,60 @@ describe('PR-5.0.97 group-bundle hydration', () => {
expect(chartFetchCount()).toBe(0);
});

it.each(['500', '503', 'network', 'timeout', 'null response'])(
'a bundle %s offers a shared retry without per-chart fanout or passive retries',
async (failure) => {
vi.useFakeTimers();
let attempts = 0;
vi.stubGlobal('fetch', (url: string | URL, init?: RequestInit) => {
fetchCalls.push(String(url));
attempts += 1;
if (attempts > 1) {
return Promise.resolve(bundleResponse(['s0', 's1', 's2']));
}
if (failure === 'null response') {
return Promise.resolve(jsonResponse(null));
}
if (failure === 'network') {
return Promise.reject(new TypeError('network unavailable'));
}
if (failure === 'timeout') {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason));
});
}
return Promise.resolve({ ok: false, status: Number(failure) } as Response);
});
await renderGroup(3);
await act(async () => {
MockIO.instances[0].fire();
MockIO.instances[1].fire();
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS);
});
expect(container.querySelectorAll('[data-role="fetch-retry"]')).toHaveLength(2);
expect(bundleFetchCount()).toBe(1);
expect(chartFetchCount()).toBe(0);

// A later card sees the same failure without starting another request.
await act(async () => {
MockIO.instances[2].fire();
await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS * 2);
});
expect(container.querySelectorAll('[data-role="fetch-retry"]')).toHaveLength(3);
expect(bundleFetchCount()).toBe(1);
const retries = container.querySelectorAll<HTMLButtonElement>('[data-role="fetch-retry"]');
await act(async () => {
retries[0].click();
retries[1].click();
});
expect(bundleFetchCount()).toBe(2);
expect(chartFetchCount()).toBe(0);
expect(container.querySelectorAll('.chart-error')).toHaveLength(0);
const chips = container.querySelectorAll<HTMLButtonElement>('[data-role="window-chip"]');
expect([...chips].every((chip) => chip.dataset.state === 'windowed')).toBe(true);
},
);

it('closing the group aborts the in-flight bundle (its fetch signal aborts)', async () => {
const signals: AbortSignal[] = [];
vi.stubGlobal('fetch', (url: string | URL, init?: { signal?: AbortSignal }) => {
Expand Down Expand Up @@ -633,8 +695,8 @@ describe('PR-5.0.97 group-bundle hydration', () => {
});

it('reopen after a bundle 404 re-issues the group bundle fetch', async () => {
// A 404 leaves `completedBundles` unset; `abortGroupBundle` (on close) clears
// `attemptedBundles`, so a reopen must re-attempt the bundle rather than
// Closing clears the settled unavailable outcome, so a reopen must
// re-attempt the bundle rather than
// short-circuit. This pins that re-attempt behavior.
vi.stubGlobal('fetch', (url: string | URL) => {
fetchCalls.push(String(url));
Expand Down
34 changes: 31 additions & 3 deletions web/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ import {
hydrationQueue,
noteChartRecentData,
noteGroupSeries,
retryGroupBundle,
subscribeGlobalFilter,
subscribeGroup,
subscribeGroupBundleRetry,
type QueueEntry,
} from '@/lib/chart-store';
import type { ChartResponse } from '@/lib/queries';
Expand Down Expand Up @@ -409,6 +411,8 @@ class ChartController {
private readonly aborter = new AbortController();
/** Failed Chart.js dynamic-import attempts; bounds the error-dismiss retry. */
private loadAttempts = 0;
private bundleFailed = false;
private readonly unsubscribeBundleRetry?: () => void;

constructor(
private readonly slug: string,
Expand Down Expand Up @@ -440,6 +444,15 @@ class ChartController {
wheelAttached: false,
disposed: false,
};
if (groupSlug) {
this.unsubscribeBundleRetry = subscribeGroupBundleRetry(groupSlug, () => {
if (this.bundleFailed && this.groupIsOpen() && !this.state.disposed) {
this.bundleFailed = false;
this.cb.setError(null);
this.onGroupOpen(0);
}
});
}
}

/** Seed the permalink page's server-fetched payload before any fetch runs. */
Expand Down Expand Up @@ -499,27 +512,36 @@ class ChartController {
}
// On the landing page (a group slug is present), drive one bundle fetch per
// group and hydrate from it. Only fall through to the per-chart fetch when
// the bundle is unavailable (404 / failed / this slug missing).
// the bundle is unavailable (404) or this slug is missing.
if (this.groupSlug) {
if (showLoading) {
this.cb.setError(null);
this.cb.setLoading(true);
this.cb.setRetryable(false);
}
const groupSlug = this.groupSlug;
return ensureGroupBundle(groupSlug, priority).then(() => {
this.bundleFailed = false;
return ensureGroupBundle(groupSlug, priority).then((result) => {
// The group can close while this card awaits the in-flight bundle. The
// close runs `abortInFlightFetches` + `abortGroupBundle` already, so a
// per-chart fallback issued now would never be aborted and would defeat
// the "closing a group frees server capacity" contract. Bail when the
// group is no longer open.
if (state.disposed || state.payload || !this.groupIsOpen()) {
if (state.disposed || state.payload || !this.groupIsOpen() || result.status === 'aborted') {
return;
}
const fromBundle = getCachedPayload(this.slug);
if (fromBundle) {
this.seedFromCachedPayload(fromBundle);
return;
}
if (result.status === 'failed') {
this.bundleFailed = true;
this.cb.setLoading(false);
this.cb.setError('failed to load group charts');
this.cb.setRetryable(true);
return;
}
// Bundle did not cover this chart: fall back to the per-chart fetch.
return this.fetchInitialPayloadDirect(priority, showLoading);
});
Expand All @@ -546,6 +568,8 @@ class ChartController {
}
this.syncWindowChip();
this.cb.setLoading(false);
this.cb.setError(null);
this.cb.setRetryable(false);
if (this.groupSlug) {
noteGroupSeries(this.groupSlug, normalized.series_meta);
}
Expand Down Expand Up @@ -685,6 +709,9 @@ class ChartController {
if (this.state.disposed || this.state.payload) {
return;
}
if (this.groupSlug && this.bundleFailed && retryGroupBundle(this.groupSlug)) {
return;
}
this.cb.setError(null);
this.cb.setRetryable(false);
void this.ensureInitialPayload(0, true).then(() => {
Expand Down Expand Up @@ -1714,6 +1741,7 @@ class ChartController {
* the mount effect constructs a fresh controller for the next mount. */
destroy(): void {
this.state.disposed = true;
this.unsubscribeBundleRetry?.();
this.aborter.abort(new DOMException('chart controller destroyed', 'AbortError'));
if (this.state.hoverDwellTimer !== null) {
clearTimeout(this.state.hoverDwellTimer);
Expand Down
44 changes: 44 additions & 0 deletions web/lib/chart-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
abortGroupBundle,
applyGroupMacro,
chartIsHiddenAsEmpty,
clearGroupSeriesFilter,
ensureGroupBundle,
getCachedPayload,
getGlobalFilterSnapshot,
getGroupSnapshot,
groupSeriesIsVisible,
Expand Down Expand Up @@ -349,4 +351,46 @@ describe('ensureGroupBundle empty-window classification', () => {
expect(chartIsHiddenAsEmpty(snap, 'chart-stale')).toBe(true);
expect(chartIsHiddenAsEmpty(snap, 'chart-live')).toBe(false);
});

it('ignores a late closed bundle without replacing the reopened request or its cached data', async () => {
let resolveOld!: (response: Response) => void;
let resolveNew!: (response: Response) => void;
const fetcher = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveOld = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveNew = resolve;
}),
);
vi.stubGlobal('fetch', fetcher);
const old = ensureGroupBundle('reopened', 0);
await Promise.resolve();
abortGroupBundle('reopened');
const current = ensureGroupBundle('reopened', 0);
await Promise.resolve();

const response = (value: number) =>
({
ok: true,
status: 200,
json: async () => ({ charts: [{ slug: 'cached', series: { vortex: [value] } }] }),
}) as Response;
resolveOld(response(1));
expect(await old).toEqual({ status: 'aborted' });
expect(getCachedPayload('cached')).toBeUndefined();
expect(ensureGroupBundle('reopened', 0)).toBe(current);
resolveNew(response(2));
expect(await current).toEqual({ status: 'success' });
expect(getCachedPayload('cached')?.series).toEqual({ vortex: [2] });
abortGroupBundle('reopened');
expect(await ensureGroupBundle('reopened', 0)).toEqual({ status: 'success' });
expect(fetcher).toHaveBeenCalledTimes(2);
});
});
Loading
Loading