diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 4bb1376a..94d5bb3e 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -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; } }, diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 95749e83..7f742271 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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 }) => ( @@ -2414,7 +2415,7 @@ const RichEditorPane = ({ ); } - if (isLoading) { + if (isLoading && !memo) { return (
{selectionActionBar} diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 89671cc3..8a164572 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -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(() => 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).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( diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts index fe552359..d65d5bda 100644 --- a/apps/web/src/lib/local-mirror.ts +++ b/apps/web/src/lib/local-mirror.ts @@ -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 }; } diff --git a/apps/web/src/lib/network-status.ts b/apps/web/src/lib/network-status.ts new file mode 100644 index 00000000..0ec9ee61 --- /dev/null +++ b/apps/web/src/lib/network-status.ts @@ -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(); diff --git a/apps/web/src/lib/repository.test.mjs b/apps/web/src/lib/repository.test.mjs index b74f2d4a..605266b6 100644 --- a/apps/web/src/lib/repository.test.mjs +++ b/apps/web/src/lib/repository.test.mjs @@ -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 }); diff --git a/apps/web/src/lib/repository.ts b/apps/web/src/lib/repository.ts index 9300e4bb..32dde9b8 100644 --- a/apps/web/src/lib/repository.ts +++ b/apps/web/src/lib/repository.ts @@ -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((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; }, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index da1ba735..89f353fd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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; +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; + + 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: { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 31a8c282..b6ce5e01 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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",