R65 A1-A6:Session 稳定性与 409 资源错误修复
这个提交包含在:
@@ -9,7 +9,14 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { configureHttpAuth } from "../api/http";
|
||||
import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } from "../api/types";
|
||||
import {
|
||||
isApiError,
|
||||
isSessionExpiredError,
|
||||
type AuthState,
|
||||
type LoginResponse,
|
||||
type MeResponse,
|
||||
type ProfileCompleteness,
|
||||
} from "../api/types";
|
||||
import { changePassword as changePasswordApi } from "../api/auth";
|
||||
import { clearPwaRuntimeCaches } from "../pwa";
|
||||
import {
|
||||
@@ -76,6 +83,16 @@ function clearSessionHint() {
|
||||
document.cookie = "has_session=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalBootstrapError(error: unknown) {
|
||||
return isSessionExpiredError(error) || (isApiError(error) && error.status === 401);
|
||||
}
|
||||
|
||||
function normalizeProfileCompleteness(value: unknown): ProfileCompleteness {
|
||||
if (!value || typeof value !== "object") {
|
||||
return DEFAULT_PROFILE_COMPLETENESS;
|
||||
@@ -353,19 +370,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
|
||||
if (!bootstrapPromiseRef.current) {
|
||||
bootstrapPromiseRef.current = (async () => {
|
||||
try {
|
||||
const refreshed = await runRefresh();
|
||||
const completeBootstrap = (refreshed: LoginResponse | null) => {
|
||||
if (refreshed?.expiresIn) {
|
||||
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||
}
|
||||
|
||||
return refreshed?.accessToken ?? null;
|
||||
} catch {
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
};
|
||||
|
||||
try {
|
||||
return completeBootstrap(await runRefresh());
|
||||
} catch (error) {
|
||||
if (isTerminalBootstrapError(error)) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await wait(1000);
|
||||
return completeBootstrap(await runRefresh());
|
||||
} catch (retryError) {
|
||||
if (isTerminalBootstrapError(retryError)) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
clearSession();
|
||||
return null;
|
||||
} finally {
|
||||
bootstrapPromiseRef.current = null;
|
||||
}
|
||||
|
||||
+97
-1
@@ -7,9 +7,105 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { queryClient } from "./query/queryClient";
|
||||
import { AppRouter } from "./router";
|
||||
import { registerPwaIfEnabled } from "./pwa";
|
||||
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
|
||||
import "./index.css";
|
||||
|
||||
const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted";
|
||||
const CHUNK_LOAD_ERROR_PATTERNS = [
|
||||
"chunkloaderror",
|
||||
"loading chunk",
|
||||
"failed to fetch dynamically imported module",
|
||||
"importing a module script failed",
|
||||
"error loading dynamically imported module",
|
||||
];
|
||||
|
||||
function extractErrorMessages(value: unknown): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return [value.name, value.message];
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = value as {
|
||||
message?: unknown;
|
||||
reason?: unknown;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
return [
|
||||
...extractErrorMessages(record.message),
|
||||
...extractErrorMessages(record.reason),
|
||||
...extractErrorMessages(record.payload),
|
||||
...extractErrorMessages(record.error),
|
||||
];
|
||||
}
|
||||
|
||||
function isChunkLoadFailure(value: unknown) {
|
||||
return extractErrorMessages(value).some((message) => {
|
||||
const normalized = message.toLowerCase();
|
||||
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
});
|
||||
}
|
||||
|
||||
function installChunkLoadRecovery() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let reloadTriggered = false;
|
||||
const reloadForChunkFailure = () => {
|
||||
if (reloadTriggered || window.sessionStorage.getItem(CHUNK_RELOAD_ATTEMPTED_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadTriggered = true;
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_ATTEMPTED_KEY, "1");
|
||||
void clearPwaRuntimeCaches()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
if (!isChunkLoadFailure(event.payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
if (!isChunkLoadFailure(event.error ?? event.message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (!isChunkLoadFailure(event.reason)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
}
|
||||
|
||||
installChunkLoadRecovery();
|
||||
registerPwaIfEnabled();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
+36
-1
@@ -5,6 +5,29 @@ const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
|
||||
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
|
||||
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
|
||||
|
||||
function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!canUseWindow() || !registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const watchInstallingWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("statechange", () => {
|
||||
if (worker.state === "installed" && window.navigator.serviceWorker.controller) {
|
||||
void clearPwaRuntimeCaches();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watchInstallingWorker(registration.installing);
|
||||
registration.addEventListener("updatefound", () => {
|
||||
watchInstallingWorker(registration.installing);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerPwaIfEnabled() {
|
||||
if (import.meta.env.DEV) {
|
||||
return;
|
||||
@@ -14,13 +37,25 @@ export function registerPwaIfEnabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
registerSW({
|
||||
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
|
||||
|
||||
updateServiceWorker = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
void clearPwaRuntimeCaches()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
void updateServiceWorker?.(true);
|
||||
});
|
||||
},
|
||||
onOfflineReady() {
|
||||
if (canUseWindow()) {
|
||||
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
|
||||
}
|
||||
},
|
||||
onRegisteredSW(_swScriptUrl, registration) {
|
||||
bindServiceWorkerUpdateCleanup(registration);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户