When a new version is deployed, open tabs no longer silently run stale code until a manual F5. - Server stamps every response with X-App-Version (src/middleware/version.ts, onSend hook in server.ts). - Client bakes its build version via Vite define (__APP_VERSION__); apiFetch compares the header to it (no polling — piggybacks existing traffic) and latches a mismatch in a small store (appVersion.ts). - UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress form is never interrupted. - lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s after a deploy (sessionStorage-guarded against loops), fixing the "old chunk gone" error screen. Approach chosen after researching current SPA practice (version header + banner beats polling / a service worker for a non-PWA admin app). Tests: +4 (server header, client store latch/notify). Full suite 669 pass, tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner appears on a simulated mismatch). Detection applies to deploys from the NEXT release onward (current clients lack the detection code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
4.7 KiB
TypeScript
178 lines
4.7 KiB
TypeScript
import { reportServerVersion } from "./appVersion";
|
|
|
|
class ApiState {
|
|
showSessionExpiredAlert = false;
|
|
showLogoutAlert = false;
|
|
getTokenFn: (() => string | null) | null = null;
|
|
refreshFn: (() => Promise<boolean>) | null = null;
|
|
refreshPromise: Promise<boolean> | null = null;
|
|
|
|
reset() {
|
|
this.showSessionExpiredAlert = false;
|
|
this.showLogoutAlert = false;
|
|
this.getTokenFn = null;
|
|
this.refreshFn = null;
|
|
this.refreshPromise = null;
|
|
}
|
|
}
|
|
|
|
const state = new ApiState();
|
|
|
|
export const resetApiState = (): void => {
|
|
state.reset();
|
|
};
|
|
|
|
export const shouldShowSessionExpiredAlert = (): boolean => {
|
|
if (state.showSessionExpiredAlert) {
|
|
state.showSessionExpiredAlert = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
export const setSessionExpired = (): void => {
|
|
state.showSessionExpiredAlert = true;
|
|
};
|
|
|
|
export const shouldShowLogoutAlert = (): boolean => {
|
|
if (state.showLogoutAlert) {
|
|
state.showLogoutAlert = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
export const setLogoutAlert = (): void => {
|
|
state.showLogoutAlert = true;
|
|
};
|
|
|
|
export const setTokenGetter = (fn: () => string | null): void => {
|
|
state.getTokenFn = fn;
|
|
};
|
|
|
|
export const setRefreshFn = (fn: () => Promise<boolean>): void => {
|
|
state.refreshFn = fn;
|
|
};
|
|
|
|
export const apiFetch = async (
|
|
url: string,
|
|
options: RequestInit = {},
|
|
): Promise<Response> => {
|
|
// Honour abort signals up-front — TanStack Query may cancel a stale
|
|
// request before we've done any work.
|
|
if (options.signal?.aborted) {
|
|
return new Response(null, { status: 499 });
|
|
}
|
|
|
|
let token: string | null = null;
|
|
try {
|
|
token = state.getTokenFn ? state.getTokenFn() : null;
|
|
} catch {
|
|
// token retrieval failed
|
|
}
|
|
|
|
// No usable token yet (e.g. fresh page load where the silent refresh
|
|
// kicked off in useEffect hasn't populated the in-memory access token).
|
|
// Refresh up front, BEFORE firing the request, so the caller never sees
|
|
// a 401 in the network log on the first attempt. Concurrent callers join
|
|
// the same in-flight refresh via state.refreshPromise.
|
|
if (!token && state.refreshFn) {
|
|
try {
|
|
if (!state.refreshPromise) {
|
|
state.refreshPromise = state.refreshFn().finally(() => {
|
|
state.refreshPromise = null;
|
|
});
|
|
}
|
|
const refreshed = await state.refreshPromise;
|
|
if (options.signal?.aborted) {
|
|
return new Response(null, { status: 499 });
|
|
}
|
|
if (refreshed) {
|
|
try {
|
|
token = state.getTokenFn ? state.getTokenFn() : null;
|
|
} catch {
|
|
token = null;
|
|
}
|
|
}
|
|
} catch {
|
|
// Refresh itself failed — fall through and let the request fire
|
|
// without a token. The 401 handler below will set a session-expired
|
|
// flag so the UI can react.
|
|
}
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
|
|
if (
|
|
!headers["Content-Type"] &&
|
|
options.body &&
|
|
!(options.body instanceof FormData)
|
|
) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
|
|
let response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
// Detect a new deploy: compare the server's build version to ours.
|
|
reportServerVersion(response.headers.get("X-App-Version"));
|
|
|
|
if (response.status === 401 && state.refreshFn) {
|
|
// If the caller already unmounted (TanStack Query aborted the request),
|
|
// do not refresh + retry. Doing so would fire a global "session expired"
|
|
// toast on a page the user has already left and leak a fetch+refresh pair.
|
|
if (options.signal?.aborted) {
|
|
return response;
|
|
}
|
|
try {
|
|
if (!state.refreshPromise) {
|
|
state.refreshPromise = state.refreshFn().finally(() => {
|
|
state.refreshPromise = null;
|
|
});
|
|
}
|
|
const refreshed = await state.refreshPromise;
|
|
// Re-check after the await: the caller may have unmounted while the
|
|
// refresh was in flight (refresh + abort race).
|
|
if (options.signal?.aborted) {
|
|
return response;
|
|
}
|
|
if (refreshed) {
|
|
token = state.getTokenFn ? state.getTokenFn() : null;
|
|
if (token) {
|
|
headers["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
const { signal: _signal, ...retryOptions } = options;
|
|
response = await fetch(url, {
|
|
...retryOptions,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
} else {
|
|
setSessionExpired();
|
|
}
|
|
} catch {
|
|
setSessionExpired();
|
|
}
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
export const getAccessToken = (): string | null => {
|
|
try {
|
|
return state.getTokenFn ? state.getTokenFn() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export default apiFetch;
|