Skip to content
Merged
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
13 changes: 11 additions & 2 deletions frontend/apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Route, Routes } from 'react-router-dom';
import { Route, Routes, useParams } from 'react-router-dom';

import { ApiError, useRegistry } from '@dar/data';

Expand All @@ -11,6 +11,15 @@ import { LoginPage } from './pages/LoginPage';
import { CreatePage } from './pages/CreatePage';
import { ToastProvider } from './toast';

// Remount ListPage when the model changes so per-model state (selection,
// retained "keep previous data" rows) resets cleanly on a model switch —
// while filter / page / search changes within a model keep the same
// instance so only the table skeletons, not the whole page (#368).
function KeyedListPage() {
const { appLabel, modelName } = useParams<{ appLabel: string; modelName: string }>();
return <ListPage key={`${appLabel}/${modelName}`} />;
}

export function App() {
const registry = useRegistry();

Expand All @@ -36,7 +45,7 @@ export function App() {
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path=":appLabel/:modelName" element={<ListPage />} />
<Route path=":appLabel/:modelName" element={<KeyedListPage />} />
{/* Literal `add` is ranked above the `:pk` route by React
Router, so /app/model/add opens the create form, not a
detail with pk="add". */}
Expand Down
5 changes: 5 additions & 0 deletions frontend/packages/data/src/list-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export function useList(params: UseListParams): ListState {
// Keep the list live without a manual reload: poll in the
// background and re-validate on focus (the hook's default).
refetchInterval: LIST_REFETCH_INTERVAL_MS,
// On a filter / page / search change the cache key changes; keep the
// prior response on screen so the page chrome (columns, header) stays
// put and only the table shows a loading skeleton, instead of the
// whole page blanking to a full-page skeleton (#368).
keepPreviousData: true,
});
}

Expand Down
56 changes: 56 additions & 0 deletions frontend/packages/data/src/swr-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,60 @@ describe('useSwrCache', () => {
expect(raw).not.toBeNull();
expect(JSON.parse(raw as string)).toEqual({ v: 'persisted' });
});

it('blanks to a loading state on a key change by default (#416)', async () => {
// Detail-style: a new key with no cache must not keep the previous
// record's data on screen — it would flash the wrong record.
const fetcher = vi.fn().mockResolvedValue({ v: 'a' });
const { result, rerender } = renderHook(
({ k }: { k: string }) => useSwrCache({ cacheKey: k, fetcher, ...opts }),
{ initialProps: { k: 'kc-a' } },
);
await waitFor(() => expect(result.current.data).toEqual({ v: 'a' }));

fetcher.mockResolvedValueOnce({ v: 'b' });
rerender({ k: 'kc-b' });
// New uncached key → blanked + loading until the fetch lands.
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.data).toEqual({ v: 'b' }));
});

it('keeps the previous data on a key change when keepPreviousData is set (#368)', async () => {
// List-style: a filter change (new key, no cache) keeps the prior
// rows on screen and shows a foreground load, so the page chrome
// stays put and only the table skeletons.
const fetcher = vi.fn().mockResolvedValue({ v: 'page1' });
const { result, rerender } = renderHook(
({ k }: { k: string }) =>
useSwrCache({ cacheKey: k, fetcher, keepPreviousData: true, ...opts }),
{ initialProps: { k: 'kp-1' } },
);
await waitFor(() => expect(result.current.data).toEqual({ v: 'page1' }));

fetcher.mockResolvedValueOnce({ v: 'page2' });
rerender({ k: 'kp-2' });
// Previous data retained (not blanked) while loading the new key.
expect(result.current.data).toEqual({ v: 'page1' });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.data).toEqual({ v: 'page2' }));
});

it('adopts the new key cached value immediately even with keepPreviousData', async () => {
window.localStorage.setItem('kp-b', JSON.stringify({ v: 'cached-b' }));
const fetcher = vi.fn().mockResolvedValue({ v: 'fresh-a' });
const { result, rerender } = renderHook(
({ k }: { k: string }) =>
useSwrCache({ cacheKey: k, fetcher, keepPreviousData: true, ...opts }),
{ initialProps: { k: 'kp-a' } },
);
await waitFor(() => expect(result.current.data).toEqual({ v: 'fresh-a' }));

fetcher.mockResolvedValueOnce({ v: 'fresh-b' });
rerender({ k: 'kp-b' });
// The new key has a cache → show it at once (no stale-a, no blank),
// then revalidate to the canonical value.
expect(result.current.data).toEqual({ v: 'cached-b' });
await waitFor(() => expect(result.current.data).toEqual({ v: 'fresh-b' }));
});
});
34 changes: 28 additions & 6 deletions frontend/packages/data/src/swr-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ export interface UseSwrCacheArgs<T> {
* current data, not whatever was cached when you left.
*/
refetchOnFocus?: boolean;
/**
* What to show on screen while the *next* key's value loads after the
* cache key changes.
*
* - `false` (default): adopt the new key's cached value immediately,
* blanking to a loading state when it has none. Right for a detail
* view, where keeping the previous object's fields on screen would
* flash the *wrong* record (#416).
* - `true`: keep the previous value on screen and show a foreground
* load instead of blanking. Right for a list, where the chrome
* (columns/header) is key-independent and only the table rows change
* — so a filter/page change skeletons just the table, not the whole
* page (#368).
*/
keepPreviousData?: boolean;
}

function readCache<T>(key: string): T | null {
Expand Down Expand Up @@ -81,23 +96,30 @@ export function useSwrCache<T>({
deps = [],
refetchInterval = 0,
refetchOnFocus = true,
keepPreviousData = false,
}: UseSwrCacheArgs<T>): SwrState<T> {
const cached = useMemo<T | null>(() => readCache<T>(cacheKey), [cacheKey]);
const [data, setData] = useState<T | null>(cached);
const [loading, setLoading] = useState<boolean>(cached === null);
const [error, setError] = useState<Error | null>(null);

// When the cache key changes (e.g. navigating from one object's detail
// to a different object's), reset to the NEW key's cached value during
// render. Otherwise `data` keeps the previous key's value — `useState`
// initializers only run on mount — and the view shows stale content
// until the background fetch lands (#416: detail→detail nav flashed the
// prior record). Setting state during render is a supported React
// to a different object's, or changing a list filter), reconcile state
// during render. `useState` initializers only run on mount, so without
// this `data` would keep the previous key's value until the background
// fetch lands. Setting state during render is a supported React
// pattern: it re-renders immediately, before paint, so no flicker.
//
// `keepPreviousData` chooses the behaviour: the default blanks to the
// new key's cache (so a detail view never flashes the wrong record,
// #416); `true` keeps the previous value on screen and just shows a
// foreground load (so a list filter change skeletons only the table,
// not the whole page, #368). The new key's cached value is still
// adopted immediately when it exists.
const keyRef = useRef(cacheKey);
if (keyRef.current !== cacheKey) {
keyRef.current = cacheKey;
setData(cached);
if (!keepPreviousData || cached !== null) setData(cached);
setLoading(cached === null);
setError(null);
}
Expand Down
Loading