fix web memo detail loading

This commit is contained in:
tianma-if committed 2026-07-28 19:37:54 +08:00
1 parent e1a2b2d08b
commit a800bcc64e
9 files changed
+276 -49

No files matched your search

+2 -1
View File
@@ -19,6 +19,7 @@ import {
saveDesktopApiBaseUrl,
} from "@/lib/api";
import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes";
import { isBrowserOffline } from "@/lib/network-status";
import type { AuthSession } from "@edgeever/shared";
const EvernoteImportGuidePane = lazy(() =>
@@ -113,7 +114,7 @@ const AuthenticatedWorkspace = () => {
return session;
} catch (error) {
const cached = getCachedDesktopSession();
if (cached?.authenticated && typeof navigator !== "undefined" && !navigator.onLine) return cached;
if (cached?.authenticated && isBrowserOffline()) return cached;
throw error;
}
},
+3 -2
View File
@@ -108,6 +108,7 @@ import { fetchLatestRelease, isVersionOutdated } from "@/lib/version-check";
import { RELEASE_STATUS_EVENT } from "@/lib/release-notice";
import { downloadMarkdownFile } from "@/lib/note-markdown-export";
import { openNotePrintPreview, serializeNoteDocumentForPrint } from "@/lib/note-print";
import { isBrowserOffline } from "@/lib/network-status";
const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
const MOBILE_EDITOR_QUERY = "(max-width: 639px)";
@@ -125,7 +126,7 @@ const createLocalEditSession = (memo: MemoDetail): MemoEditSession => ({
const requiresLocalEditSession = (memo: MemoDetail) =>
isDesktopResourceRuntime() ||
isLocalMemoId(memo.id) ||
(typeof navigator !== "undefined" && !navigator.onLine);
isBrowserOffline();
const IconTooltip = ({ label, children }: { label: string; children: ReactNode }) => (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
@@ -2414,7 +2415,7 @@ const RichEditorPane = ({
);
}
if (isLoading) {
if (isLoading && !memo) {
return (
<div className="flex h-full min-w-0 flex-col bg-white">
{selectionActionBar}
+27 -11
View File
@@ -37,6 +37,7 @@ import {
type MobileEditorReturnPreview,
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createExcerpt, docToText, getNotebookDescendantIds, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type Resource, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
@@ -727,7 +728,7 @@ export const WorkspaceApp = ({
const [mobileEditorReturnPreview, setMobileEditorReturnPreview] = useState<MobileEditorReturnPreview | null>(() =>
readMobileEditorReturnPreview(getMobileEditorReturnMemoId(location.search))
);
const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine));
const [isOnline, setIsOnline] = useState(isBrowserOnline);
const [isDesktop, setIsDesktop] = useState(isDesktopViewport);
const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false);
const [isManualMemoSyncing, setIsManualMemoSyncing] = useState(false);
@@ -769,7 +770,7 @@ export const WorkspaceApp = ({
};
const runQueuedSync = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
if (isBrowserOffline()) {
setIsOnline(false);
return;
}
@@ -873,7 +874,7 @@ export const WorkspaceApp = ({
}, [isOnline, queryClient]);
const refreshLatestMemos = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
if (isBrowserOffline()) {
setIsOnline(false);
setPullToRefreshDistance(0);
return;
@@ -894,7 +895,7 @@ export const WorkspaceApp = ({
}, [isOnline, localDataScope, queryClient]);
const syncMemosManually = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
if (isBrowserOffline()) {
setIsOnline(false);
return;
}
@@ -1363,7 +1364,7 @@ export const WorkspaceApp = ({
useEffect(() => {
const updateOnlineState = () => {
const online = navigator.onLine;
const online = isBrowserOnline();
setIsOnline(online);
if (online) {
void runQueuedSync();
@@ -1394,7 +1395,7 @@ export const WorkspaceApp = ({
}
const refreshWorkspaceQueries = () => {
if (document.visibilityState === "hidden" || (typeof navigator !== "undefined" && !navigator.onLine)) {
if (document.visibilityState === "hidden" || isBrowserOffline()) {
return;
}
@@ -1480,6 +1481,7 @@ export const WorkspaceApp = ({
const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null;
const nextMemoId =
selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null;
const detailMemoId = selectedMemoId ?? memos[0]?.id ?? null;
useEffect(() => {
const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false;
@@ -1502,11 +1504,25 @@ export const WorkspaceApp = ({
}, [createdMemoEditId, memos, selectedMemoId]);
const memoQuery = useQuery({
queryKey: selectedMemoId ? memoDetailQueryKey(selectedMemoId, memoView) : ["memo", selectedMemoId, memoView],
queryFn: () => repository.getMemo(selectedMemoId as string, memoView === "trash"),
enabled: Boolean(selectedMemoId),
queryKey: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView],
queryFn: () => repository.getMemo(detailMemoId as string, memoView === "trash"),
enabled: Boolean(detailMemoId),
});
useEffect(() => {
const handleMemoDetailRefreshed = (event: Event) => {
const memo = (event as CustomEvent<MemoDetail>).detail;
if (!memo?.id) return;
queryClient.setQueryData(memoDetailQueryKey(memo.id, "notebook"), { memo });
queryClient.setQueryData(memoDetailQueryKey(memo.id, "trash"), { memo });
updateMemoSummaryInLists(queryClient, memoToSummary(memo));
};
window.addEventListener("edgeever:memo-detail-refreshed", handleMemoDetailRefreshed);
return () => window.removeEventListener("edgeever:memo-detail-refreshed", handleMemoDetailRefreshed);
}, [queryClient]);
const createNotebookMutation = useMutation({
mutationFn: repository.createNotebook,
onSuccess: async (data) => {
@@ -1895,8 +1911,8 @@ export const WorkspaceApp = ({
});
const selectedNotebook = notebooks.find((notebook) => notebook.id === selectedNotebookId) ?? null;
const cachedSelectedMemo = selectedMemoId
? queryClient.getQueryData<{ memo: MemoDetail }>(memoDetailQueryKey(selectedMemoId, memoView))?.memo ?? null
const cachedSelectedMemo = detailMemoId
? queryClient.getQueryData<{ memo: MemoDetail }>(memoDetailQueryKey(detailMemoId, memoView))?.memo ?? null
: null;
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
const desktopFocusModeActive = Boolean(
+2 -1
View File
@@ -3,6 +3,7 @@ import type { MemoFilterMode, MemoSortMode } from "@/lib/app-helpers";
import { api, type SyncChangesResponse } from "@/lib/api";
import { localDb, type LocalMemo, type LocalNotebook, type LocalResource, type LocalRevision } from "@/lib/local-db";
import { cacheLocalResourceBytes, localResourceUrl, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache";
import { isBrowserOffline } from "@/lib/network-status";
export type LocalMemoListParams = {
notebookId?: string | null;
@@ -122,7 +123,7 @@ const bootstrapScope = async (scope: string) => {
};
const performSyncLocalMirror = async (scope: string) => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
if (isBrowserOffline()) {
return { bootstrapped: false, changed: 0 };
}
+9
View File
@@ -0,0 +1,9 @@
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
const isLoopbackOrigin = () =>
typeof window !== "undefined" && LOOPBACK_HOSTNAMES.has(window.location.hostname);
export const isBrowserOffline = () =>
typeof navigator !== "undefined" && navigator.onLine === false && !isLoopbackOrigin();
export const isBrowserOnline = () => !isBrowserOffline();
+85
View File
@@ -22,6 +22,91 @@ afterEach(async () => {
});
describe("web repository offline boundaries", () => {
test("falls back to the remote detail when the local database read is blocked", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
const scope = "https://demo.edgeever.org|user-1";
const remoteMemo = {
id: "memo-blocked",
notebookId: "nb-1",
title: "Remote detail",
excerpt: "Remote excerpt",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "remote",
contentText: "remote",
contentHash: "remote",
sourceMemoIds: [],
};
const originalLocalGet = localDb.memos.get;
const originalLocalPut = localDb.memos.put;
const originalApiGetMemo = api.getMemo;
localDb.memos.get = async () => new Promise(() => {});
localDb.memos.put = async () => new Promise(() => {});
api.getMemo = async () => ({ memo: remoteMemo });
try {
const repository = createWebRepository(scope);
const startedAt = Date.now();
expect((await repository.getMemo("memo-blocked")).memo.title).toBe("Remote detail");
expect(Date.now() - startedAt).toBeLessThan(1_000);
} finally {
localDb.memos.get = originalLocalGet;
localDb.memos.put = originalLocalPut;
api.getMemo = originalApiGetMemo;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
}
});
test("returns cached detail immediately and refreshes it in the background", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
const scope = "https://demo.edgeever.org|user-1";
const localMemo = {
id: "memo-1",
notebookId: "nb-1",
title: "Cached title",
excerpt: "Cached excerpt",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: "cached",
contentText: "cached",
contentHash: "cached",
sourceMemoIds: [],
};
const remoteMemo = { ...localMemo, title: "Remote title", contentMarkdown: "remote", contentText: "remote", revision: 2 };
await localDb.memos.put({ ...localMemo, scope });
const originalGetMemo = api.getMemo;
api.getMemo = async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
return { memo: remoteMemo };
};
try {
const repository = createWebRepository(scope);
expect((await repository.getMemo("memo-1")).memo.title).toBe("Cached title");
await new Promise((resolve) => setTimeout(resolve, 30));
expect((await localDb.memos.get([scope, "memo-1"])).title).toBe("Remote title");
} finally {
api.getMemo = originalGetMemo;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
}
});
test("returns empty initialized collections without cloud fallbacks", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: false });
+64 -7
View File
@@ -40,6 +40,7 @@ import {
import { queueLocalAction, queueMemoCreate, queueMemoDelete, queueMemoRestore, queueMemoUpdate } from "@/lib/sync-queue";
import type { MemoUpdateSyncPayload } from "@/lib/local-db";
import { createDesktopRepository } from "@/lib/desktop-repository";
import { isBrowserOffline } from "@/lib/network-status";
export type EdgeEverRepository = {
listNotebooks(): Promise<{ notebooks: Notebook[] }>;
@@ -73,8 +74,24 @@ export type EdgeEverRepository = {
sync(): Promise<{ bootstrapped: boolean; changed: number }>;
};
const LOCAL_MEMO_READ_TIMEOUT_MS = 300;
const getLocalMemoWithoutBlocking = (scope: string, memoId: string) =>
Promise.race([
getLocalMemo(scope, memoId),
new Promise<null>((resolve) => {
globalThis.setTimeout(() => resolve(null), LOCAL_MEMO_READ_TIMEOUT_MS);
}),
]);
const cacheMemoWithoutBlocking = (scope: string, memo: MemoDetail) => {
void putLocalMemo(scope, memo).catch(() => {
// Cache persistence must never prevent a remote detail from rendering.
});
};
export const createWebRepository = (scope: string): EdgeEverRepository => {
const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine;
const isOffline = isBrowserOffline;
return ({
async listNotebooks() {
@@ -185,7 +202,7 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
return { memo };
},
uploadMemoResource: async (memoId, file) => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
if (isOffline()) {
const resource = await createLocalResource(scope, memoId, file);
await queueLocalAction(scope, "resource.create", resource.id, {
resourceId: resource.id,
@@ -282,12 +299,52 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
},
async getMemo(memoId, includeDeleted = false) {
const local = await getLocalMemo(scope, memoId);
if (local && (includeDeleted || !local.isDeleted)) return { memo: local };
const localPromise = getLocalMemoWithoutBlocking(scope, memoId);
if (isOffline()) throw new Error("Memo is not available in the local mirror");
const remote = await api.getMemo(memoId, { includeDeleted });
await putLocalMemo(scope, remote.memo);
if (isOffline()) {
const local = await localPromise;
const usableLocal = local && (includeDeleted || !local.isDeleted) ? local : null;
if (usableLocal) return { memo: usableLocal };
throw new Error("Memo is not available in the local mirror");
}
const remotePromise = api.getMemo(memoId, { includeDeleted });
const firstResult = await Promise.race([
localPromise.then((local) => ({ source: "local" as const, local })),
remotePromise.then(
(remote) => ({ source: "remote" as const, remote }),
(error: unknown) => ({ source: "remote-error" as const, error }),
),
]);
if (firstResult.source === "remote") {
cacheMemoWithoutBlocking(scope, firstResult.remote.memo);
return firstResult.remote;
}
const local = firstResult.source === "local" ? firstResult.local : await localPromise;
const usableLocal = local && (includeDeleted || !local.isDeleted) ? local : null;
if (usableLocal) {
if (firstResult.source === "local") {
void remotePromise
.then((remote) => {
cacheMemoWithoutBlocking(scope, remote.memo);
window.dispatchEvent(new CustomEvent("edgeever:memo-detail-refreshed", { detail: remote.memo }));
})
.catch(() => {
// The local detail remains usable when the background refresh fails.
});
}
return { memo: usableLocal };
}
if (firstResult.source === "remote-error") {
throw firstResult.error;
}
const remote = await remotePromise;
cacheMemoWithoutBlocking(scope, remote.memo);
return remote;
},
+53 -26
View File
@@ -10,38 +10,65 @@ import { initializeTheme, ThemeProvider } from "./components/ThemeProvider";
import "./styles/globals.css";
const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000;
const DEVELOPMENT_PWA_RELOAD_KEY = "edgeever.dev-pwa-reset";
let updateServiceWorker: ReturnType<typeof registerSW>;
const clearDevelopmentPwaState = async () => {
if (!("serviceWorker" in navigator)) {
return;
}
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
emitPwaUpdateNotice({ buildLabel: __EDGEEVER_BUILD_LABEL__, kind: "checking" });
void updateServiceWorker(true);
},
onNeedReload() {
markPwaUpdateReloadPending();
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
if ("caches" in window) {
const cacheNames = await window.caches.keys();
await Promise.all(cacheNames.map((cacheName) => window.caches.delete(cacheName)));
}
if (navigator.serviceWorker.controller && window.sessionStorage.getItem(DEVELOPMENT_PWA_RELOAD_KEY) !== "1") {
window.sessionStorage.setItem(DEVELOPMENT_PWA_RELOAD_KEY, "1");
window.location.reload();
},
onRegisteredSW(_swScriptUrl, registration) {
if (!registration) {
return;
}
return;
}
const checkForUpdate = () => {
if (document.visibilityState === "visible") {
void registration.update().catch(() => undefined);
window.sessionStorage.removeItem(DEVELOPMENT_PWA_RELOAD_KEY);
};
if (import.meta.env.DEV) {
void clearDevelopmentPwaState();
} else {
let updateServiceWorker: ReturnType<typeof registerSW>;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
emitPwaUpdateNotice({ buildLabel: __EDGEEVER_BUILD_LABEL__, kind: "checking" });
void updateServiceWorker(true);
},
onNeedReload() {
markPwaUpdateReloadPending();
window.location.reload();
},
onRegisteredSW(_swScriptUrl, registration) {
if (!registration) {
return;
}
};
const updateInterval = window.setInterval(checkForUpdate, PWA_UPDATE_CHECK_INTERVAL_MS);
window.addEventListener("beforeunload", () => window.clearInterval(updateInterval), { once: true });
document.addEventListener("visibilitychange", checkForUpdate);
},
onRegisterError(error) {
console.warn("PWA service worker registration failed", error);
},
});
const checkForUpdate = () => {
if (document.visibilityState === "visible") {
void registration.update().catch(() => undefined);
}
};
const updateInterval = window.setInterval(checkForUpdate, PWA_UPDATE_CHECK_INTERVAL_MS);
window.addEventListener("beforeunload", () => window.clearInterval(updateInterval), { once: true });
document.addEventListener("visibilitychange", checkForUpdate);
},
onRegisterError(error) {
console.warn("PWA service worker registration failed", error);
},
});
}
const queryClient = new QueryClient({
defaultOptions: {
+31 -1
View File
@@ -2,7 +2,7 @@ import react from "@vitejs/plugin-react";
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { resolveAppVersion, resolveDeploymentMethod, resolveDeploymentTrigger, resolveReleaseTimestamp } from "./build-metadata";
@@ -71,6 +71,35 @@ const deploymentMethod = resolveDeploymentMethod(
: undefined)
);
const isDesktopBuild = process.env.EDGE_EVER_DESKTOP_BUILD === "1";
const developmentServiceWorkerReset: Plugin = {
name: "edgeever-development-service-worker-reset",
apply: "serve",
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url?.split("?")[0] !== "/sw.js") {
next();
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "application/javascript; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(`
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
await self.clients.claim();
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: "window" });
await Promise.all(clients.map((client) => client.navigate(client.url)));
})());
});
`);
});
},
};
export default defineConfig({
root: "apps/web",
@@ -86,6 +115,7 @@ export default defineConfig({
__EDGEEVER_DEPLOYMENT_METHOD__: JSON.stringify(deploymentMethod),
},
plugins: [
developmentServiceWorkerReset,
react(),
VitePWA({
registerType: "autoUpdate",