From c4b48233c1c81b3f919ac8831d38d646fc78bf7e Mon Sep 17 00:00:00 2001 From: a Date: Sat, 27 Jun 2026 19:35:18 +0300 Subject: [PATCH] fix(offline): stop offline white-screen and replay paused structural mutations Fixes the offline-sync defects QA found on PR #120 (#237/#238/#220). Blank-shell / white-screen on offline reload (HIGH): - auth-query.tsx: the useCollabToken retry predicate read `error.response.status` unguarded. Offline the collab-token POST rejects as an axios NETWORK error (isAxiosError true, response undefined), so `.status` threw an uncaught TypeError in the React Query retryer BEFORE React mounted, white-screening every route. Extracted the predicate as `collabTokenRetry` and guarded it with optional chaining (`error.response?.status === 404`). - user-provider.tsx: gated the whole on useCurrentUser() and returned a bare `<>` on any error, blanking every authenticated route offline even when cached data existed. Now renders the cached app when a (stale) user is present and an explicit OfflineFallback when there is no user to fall back on. - query-persister.ts / make-offline.ts: persist and warm the ['currentUser'] query so the auth gate can hydrate offline (pinned pages now survive relaunch). Offline structural create/move/comment silently lost on reload (HIGH): - offline-mutations.ts: register setMutationDefaults (default mutationFns) for stable mutation keys and tag useCreatePageMutation / useMovePageMutation / useCreateCommentMutation with those keys. A paused mutation dehydrated to IndexedDB while offline now has a mutationFn after reload, so resumePausedMutations() replays it on reconnect instead of no-op'ing. Tests (client vitest): collabTokenRetry no longer throws on a no-response network error; UserProvider renders cached children / the offline fallback (not a blank fragment) on a network error; a rehydrated paused create/move is replayable via resumePausedMutations; currentUser persist-root coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/auth/queries/auth-query.test.ts | 43 +++++++ .../src/features/auth/queries/auth-query.tsx | 29 +++-- .../features/comment/queries/comment-query.ts | 4 + .../src/features/offline/make-offline.ts | 22 +++- .../src/features/offline/offline-fallback.tsx | 45 +++++++ .../offline/offline-mutations.test.ts | 114 +++++++++++++++++ .../src/features/offline/offline-mutations.ts | 64 ++++++++++ .../features/offline/query-persister.test.ts | 9 +- .../src/features/offline/query-persister.ts | 8 ++ .../src/features/page/queries/page-query.ts | 8 ++ .../src/features/user/user-provider.test.tsx | 118 ++++++++++++++++++ .../src/features/user/user-provider.tsx | 21 +++- apps/client/src/main.tsx | 7 ++ 13 files changed, 480 insertions(+), 12 deletions(-) create mode 100644 apps/client/src/features/auth/queries/auth-query.test.ts create mode 100644 apps/client/src/features/offline/offline-fallback.tsx create mode 100644 apps/client/src/features/offline/offline-mutations.test.ts create mode 100644 apps/client/src/features/offline/offline-mutations.ts create mode 100644 apps/client/src/features/user/user-provider.test.tsx diff --git a/apps/client/src/features/auth/queries/auth-query.test.ts b/apps/client/src/features/auth/queries/auth-query.test.ts new file mode 100644 index 00000000..f5717c63 --- /dev/null +++ b/apps/client/src/features/auth/queries/auth-query.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { AxiosError } from "axios"; +import { collabTokenRetry } from "./auth-query"; + +// Regression for the offline white-screen (#237/#238): offline the collab-token +// POST rejects as an axios NETWORK error (isAxiosError === true but +// error.response === undefined). The old predicate read `error.response.status` +// without a guard and threw an uncaught TypeError inside the React Query retryer +// BEFORE React mounted, blanking the whole app. The predicate must stay total. +describe("collabTokenRetry", () => { + it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => { + // An axios error with no `response` is exactly the offline/network-failure shape. + const networkError = new AxiosError("Network Error"); + expect(networkError.response).toBeUndefined(); + + let result: boolean | number = false; + expect(() => { + result = collabTokenRetry(0, networkError); + }).not.toThrow(); + // Network failures stay retryable (truthy), matching the original intent. + expect(result).toBe(true); + }); + + it("returns false (no retry) for a real 404 response", () => { + const notFound = new AxiosError("Not Found"); + notFound.response = { status: 404 } as AxiosError["response"]; + expect(collabTokenRetry(0, notFound)).toBe(false); + }); + + it("retries for a non-404 response (e.g. 500)", () => { + const serverError = new AxiosError("Server Error"); + serverError.response = { status: 500 } as AxiosError["response"]; + expect(collabTokenRetry(0, serverError)).toBe(true); + }); + + it("does not throw and retries for a non-axios error", () => { + let result: boolean | number = false; + expect(() => { + result = collabTokenRetry(0, new Error("boom")); + }).not.toThrow(); + expect(result).toBe(true); + }); +}); diff --git a/apps/client/src/features/auth/queries/auth-query.tsx b/apps/client/src/features/auth/queries/auth-query.tsx index 09e758e8..0a0b7fb7 100644 --- a/apps/client/src/features/auth/queries/auth-query.tsx +++ b/apps/client/src/features/auth/queries/auth-query.tsx @@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service"; import { ICollabToken, IVerifyUserToken } from "../types/auth.types"; import { isAxiosError } from "axios"; +/** + * Retry predicate for the collab-token query. + * + * Offline (or any network failure) the POST rejects as an axios NETWORK error: + * `isAxiosError(error) === true` but `error.response === undefined`. Reading + * `error.response.status` without a guard threw an uncaught TypeError inside the + * React Query retryer BEFORE React mounted, white-screening the whole app on an + * offline cold boot (#237/#238). Optional-chaining `error.response?.status` + * keeps the predicate total: a network error (no response) is retryable, a real + * 404 is not. Extracted (and exported) so it can be unit-tested in isolation. + */ +export function collabTokenRetry( + _failureCount: number, + error: Error, +): boolean { + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + return true; +} + export function useVerifyUserTokenQuery( verify: IVerifyUserToken, ): UseQueryResult { @@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult { //refetchInterval: 12 * 60 * 60 * 1000, // 12hrs //refetchIntervalInBackground: true, refetchOnMount: true, - //@ts-ignore - retry: (failureCount, error) => { - if (isAxiosError(error) && error.response.status === 404) { - return false; - } - return 10; - }, + retry: collabTokenRetry, retryDelay: (retryAttempt) => { // Exponential backoff: 5s, 10s, 20s, etc. return 5000 * Math.pow(2, retryAttempt - 1); diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index 5d637610..56b4edd5 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -20,6 +20,7 @@ import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; import { useEffect, useMemo } from "react"; +import { offlineMutationKeys } from "@/features/offline/offline-mutations"; export const RQ_KEY = (pageId: string) => ["comments", pageId]; @@ -60,6 +61,9 @@ export function useCreateCommentMutation() { const { t } = useTranslation(); return useMutation>({ + // Stable key so a paused comment-create restored from IndexedDB after an + // offline reload finds its default mutationFn and is replayed on reconnect. + mutationKey: offlineMutationKeys.createComment, mutationFn: (data) => createComment(data), onSuccess: (newComment) => { const cache = queryClient.getQueryData( diff --git a/apps/client/src/features/offline/make-offline.ts b/apps/client/src/features/offline/make-offline.ts index b4ccd458..e013f828 100644 --- a/apps/client/src/features/offline/make-offline.ts +++ b/apps/client/src/features/offline/make-offline.ts @@ -15,6 +15,7 @@ import { import { spaceByIdQueryOptions } from "@/features/space/queries/space-query"; import { RQ_KEY } from "@/features/comment/queries/comment-query"; import { getPageComments } from "@/features/comment/services/comment-service"; +import { getMyInfo } from "@/features/user/services/user-service"; import { IPage } from "@/features/page/types/page.types"; import { IPagination } from "@/lib/types.ts"; @@ -69,7 +70,7 @@ export interface MakePageAvailableOfflineParams { /** * Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm * step succeeded; `failed` lists the labels of the steps that failed (a subset - * of: "page", "space", "tree", "breadcrumbs", "comments"). + * of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments"). */ export interface MakePageAvailableOfflineResult { ok: boolean; @@ -92,6 +93,25 @@ export async function makePageAvailableOffline({ }: MakePageAvailableOfflineParams): Promise { const failed: string[] = []; + // Warm the current user (['currentUser']) so the auth-gated can + // hydrate offline. UserProvider blanks the whole app while useCurrentUser has + // no data, and the offline POST /api/users/me fails as a network error, so + // without a persisted user a pinned page still white-screens after relaunch + // (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted + // cache actually has an entry to restore. + try { + await queryClient.prefetchQuery({ + queryKey: ["currentUser"], + queryFn: () => getMyInfo(), + }); + } catch (error) { + console.error("makePageAvailableOffline: currentUser step failed", { + pageId, + error, + }); + failed.push("currentUser"); + } + // Fetch the page document ONCE and write it under BOTH cache keys, exactly // like usePageQuery's onData effect. Every page consumer reads // pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads), diff --git a/apps/client/src/features/offline/offline-fallback.tsx b/apps/client/src/features/offline/offline-fallback.tsx new file mode 100644 index 00000000..0e2eee54 --- /dev/null +++ b/apps/client/src/features/offline/offline-fallback.tsx @@ -0,0 +1,45 @@ +import { Button, Container, Group, Stack, Text, Title } from "@mantine/core"; +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; +import { getAppName } from "@/lib/config"; + +/** + * Shown when the authenticated app shell cannot hydrate because the current + * user is unavailable AND there is no cached user to fall back on (e.g. an + * offline cold boot of a page that was never warmed for offline). + * + * Previously UserProvider returned a bare `<>` in this situation, which + * white-screened the whole app on any offline reload (#237/#238). Rendering an + * explicit "you're offline" state with a retry instead gives the user a clear, + * non-blank fallback and a way to recover once the network returns. + */ +export function OfflineFallback() { + const { t } = useTranslation(); + + return ( + <> + + + {t("You're offline")} - {getAppName()} + + + + + + {t("You're offline")} + + + {t( + "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.", + )} + + + + + + + + ); +} diff --git a/apps/client/src/features/offline/offline-mutations.test.ts b/apps/client/src/features/offline/offline-mutations.test.ts new file mode 100644 index 00000000..5245964e --- /dev/null +++ b/apps/client/src/features/offline/offline-mutations.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query"; + +// Stub the network services so a replayed mutation hits a spy, not the network. +const h = vi.hoisted(() => ({ + createPage: vi.fn(), + movePage: vi.fn(), + createComment: vi.fn(), +})); + +vi.mock("@/features/page/services/page-service", () => ({ + createPage: h.createPage, + movePage: h.movePage, +})); +vi.mock("@/features/comment/services/comment-service", () => ({ + createComment: h.createComment, +})); +// page-query pulls in the app entry (queryClient) and a lot of UI deps via its +// cache helpers; we only need invalidateOnCreatePage to be a no-op here. +vi.mock("@/features/page/queries/page-query", () => ({ + invalidateOnCreatePage: vi.fn(), +})); + +import { + offlineMutationKeys, + registerOfflineMutationDefaults, +} from "./offline-mutations"; + +beforeEach(() => { + h.createPage.mockReset().mockResolvedValue({ id: "new-page" }); + h.movePage.mockReset().mockResolvedValue(undefined); + h.createComment.mockReset().mockResolvedValue({ id: "new-comment" }); +}); + +describe("registerOfflineMutationDefaults", () => { + it("registers a default mutationFn for every offline mutation key", () => { + const qc = new QueryClient(); + registerOfflineMutationDefaults(qc); + + for (const key of Object.values(offlineMutationKeys)) { + const defaults = qc.getMutationDefaults(key); + expect(typeof defaults?.mutationFn).toBe("function"); + } + }); + + // The headline durability guarantee: a paused mutation dehydrated into + // IndexedDB while offline must, after a reload, have a mutationFn so + // resumePausedMutations() actually replays the write on reconnect. + it("makes a rehydrated paused create replayable by resumePausedMutations", async () => { + // 1) Simulate the offline tab: a paused create mutation gets dehydrated. + const offlineClient = new QueryClient(); + const observer = offlineClient.getMutationCache().build(offlineClient, { + mutationKey: offlineMutationKeys.createPage, + }); + // Force the dehydrate-worthy paused state (offline = isPaused) with the + // payload the user submitted before losing connectivity. + observer.state.isPaused = true; + observer.state.status = "pending"; + observer.state.variables = { spaceId: "s1", title: "Offline page" }; + + const dehydrated = dehydrate(offlineClient, { + shouldDehydrateMutation: () => true, + }); + expect(dehydrated.mutations).toHaveLength(1); + // The dehydrated mutation carries NO mutationFn (functions aren't + // serializable) — only its key + variables survive the reload. + expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined(); + + // 2) Simulate the fresh page after reload: register defaults, then hydrate + // the persisted paused mutation back in. + const freshClient = new QueryClient(); + registerOfflineMutationDefaults(freshClient); + hydrate(freshClient, dehydrated); + + expect(freshClient.getMutationCache().getAll()).toHaveLength(1); + + // 3) Reconnect: replay the paused mutations. + await freshClient.resumePausedMutations(); + + // The default mutationFn ran with the persisted variables — the write is + // NOT silently dropped. + expect(h.createPage).toHaveBeenCalledTimes(1); + expect(h.createPage).toHaveBeenCalledWith({ + spaceId: "s1", + title: "Offline page", + }); + }); + + it("makes a rehydrated paused move replayable by resumePausedMutations", async () => { + const offlineClient = new QueryClient(); + const observer = offlineClient.getMutationCache().build(offlineClient, { + mutationKey: offlineMutationKeys.movePage, + }); + observer.state.isPaused = true; + observer.state.status = "pending"; + observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" }; + + const dehydrated = dehydrate(offlineClient, { + shouldDehydrateMutation: () => true, + }); + + const freshClient = new QueryClient(); + registerOfflineMutationDefaults(freshClient); + hydrate(freshClient, dehydrated); + await freshClient.resumePausedMutations(); + + expect(h.movePage).toHaveBeenCalledTimes(1); + expect(h.movePage).toHaveBeenCalledWith({ + pageId: "p1", + parentPageId: null, + position: "a", + }); + }); +}); diff --git a/apps/client/src/features/offline/offline-mutations.ts b/apps/client/src/features/offline/offline-mutations.ts new file mode 100644 index 00000000..4ea139cd --- /dev/null +++ b/apps/client/src/features/offline/offline-mutations.ts @@ -0,0 +1,64 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { createPage, movePage } from "@/features/page/services/page-service"; +import { createComment } from "@/features/comment/services/comment-service"; +import { invalidateOnCreatePage } from "@/features/page/queries/page-query"; +import type { + IMovePage, + IPage, + IPageInput, +} from "@/features/page/types/page.types"; +import type { IComment } from "@/features/comment/types/comment.types"; + +/** + * Stable mutation keys for the offline-relevant structural mutations. + * + * When the browser goes offline, React Query PAUSES these mutations and the + * PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a + * reload-while-offline the mutation is restored, but a restored mutation has NO + * observer (no component is mounted) — so its replay relies entirely on the + * `mutationFn` registered via `setMutationDefaults` for its `mutationKey`. + * Without that, `resumePausedMutations()` finds a paused mutation with no + * `mutationFn` and silently no-ops, dropping the offline create/move/comment + * (#237/#238). Each offline mutation hook tags itself with the matching key so + * the rehydrated paused mutation can find its default `mutationFn` and replay. + */ +export const offlineMutationKeys = { + createPage: ["create-page"] as const, + movePage: ["move-page"] as const, + createComment: ["create-comment"] as const, +}; + +/** + * Register default `mutationFn`s (and the minimal success side effects safe to + * run without a mounted component) for the offline-relevant mutation keys, so a + * paused mutation restored from IndexedDB after an offline reload is replayable + * by `resumePausedMutations()` on reconnect. + * + * Called once when the QueryClient is created (see main.tsx). The hooks still + * carry their own inline `mutationFn`/`onSuccess` for the live in-session path; + * these defaults only take over for a rehydrated paused mutation that lost its + * observer across the reload. + */ +export function registerOfflineMutationDefaults(queryClient: QueryClient): void { + queryClient.setMutationDefaults(offlineMutationKeys.createPage, { + mutationFn: (data: Partial) => createPage(data), + // Re-converge the sidebar tree / recent-changes from the authoritative + // create response. Pure cache writes — safe with no component mounted. + onSuccess: (data: IPage) => { + invalidateOnCreatePage(data); + }, + }); + + queryClient.setMutationDefaults(offlineMutationKeys.movePage, { + // Replay the server-side move. The tree re-converges from the next online + // sidebar fetch / websocket `moveTreeNode` echo, so no cache write is + // needed here (the optimistic tree state was local-only anyway). + mutationFn: (data: IMovePage) => movePage(data), + }); + + queryClient.setMutationDefaults(offlineMutationKeys.createComment, { + // Replay the server-side comment create. The comments list refetches on the + // online reload, so the replay only needs to persist the write. + mutationFn: (data: Partial) => createComment(data), + }); +} diff --git a/apps/client/src/features/offline/query-persister.test.ts b/apps/client/src/features/offline/query-persister.test.ts index db286702..eb65245f 100644 --- a/apps/client/src/features/offline/query-persister.test.ts +++ b/apps/client/src/features/offline/query-persister.test.ts @@ -27,6 +27,10 @@ describe("shouldDehydrateOfflineQuery", () => { expect( shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])), ).toBe(true); + // currentUser is persisted so the auth-gated Layout can hydrate offline. + expect( + shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])), + ).toBe(true); }); it("returns false when the status is not success (status gate)", () => { @@ -60,7 +64,7 @@ describe("shouldDehydrateOfflineQuery", () => { }); describe("OFFLINE_PERSIST_ROOTS", () => { - it("contains exactly the expected 8 navigation/read roots", () => { + it("contains exactly the expected 9 navigation/read roots", () => { const expected = [ "pages", "sidebar-pages", @@ -70,8 +74,9 @@ describe("OFFLINE_PERSIST_ROOTS", () => { "space", "spaces", "recent-changes", + "currentUser", ]; - expect(OFFLINE_PERSIST_ROOTS.size).toBe(8); + expect(OFFLINE_PERSIST_ROOTS.size).toBe(9); for (const root of expected) { expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true); } diff --git a/apps/client/src/features/offline/query-persister.ts b/apps/client/src/features/offline/query-persister.ts index 043a0322..fabe7683 100644 --- a/apps/client/src/features/offline/query-persister.ts +++ b/apps/client/src/features/offline/query-persister.ts @@ -31,6 +31,13 @@ export const queryPersister = createAsyncStoragePersister({ // Only navigation/read query roots are persisted for offline reading. // Volatile/auth queries (collab tokens, trash lists) are intentionally excluded. +// +// `currentUser` IS persisted: UserProvider gates the entire subtree on +// useCurrentUser(), and offline the POST /api/users/me fails as a no-response +// network error. Without the persisted/hydrated user the gate blanked every +// authenticated route on an offline cold boot (#237/#238). It is the logged-in +// user's own profile (already mirrored to localStorage["currentUser"]), so +// persisting it to IndexedDB leaks nothing new while unlocking offline reads. export const OFFLINE_PERSIST_ROOTS = new Set([ "pages", "sidebar-pages", @@ -40,6 +47,7 @@ export const OFFLINE_PERSIST_ROOTS = new Set([ "space", "spaces", "recent-changes", + "currentUser", ]); export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean { diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index eb85edd2..14f3641b 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -43,6 +43,7 @@ import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification"; +import { offlineMutationKeys } from "@/features/offline/offline-mutations"; /** * Centralized React Query key factories for page queries. The hooks below and @@ -95,6 +96,10 @@ export function usePageQuery( export function useCreatePageMutation() { const { t } = useTranslation(); return useMutation>({ + // Stable key so a paused create restored from IndexedDB after an offline + // reload finds its default mutationFn (registerOfflineMutationDefaults) and + // is replayed by resumePausedMutations() on reconnect instead of being lost. + mutationKey: offlineMutationKeys.createPage, mutationFn: (data) => createPage(data), onSuccess: (data) => { invalidateOnCreatePage(data); @@ -216,6 +221,9 @@ export function useDeletePageMutation() { export function useMovePageMutation() { return useMutation({ + // Stable key so a paused move restored from IndexedDB after an offline + // reload finds its default mutationFn and is replayed on reconnect. + mutationKey: offlineMutationKeys.movePage, mutationFn: (data) => movePage(data), }); } diff --git a/apps/client/src/features/user/user-provider.test.tsx b/apps/client/src/features/user/user-provider.test.tsx new file mode 100644 index 00000000..a00faa2b --- /dev/null +++ b/apps/client/src/features/user/user-provider.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter } from "react-router-dom"; +import { HelmetProvider } from "react-helmet-async"; + +// Control useCurrentUser per test; stub the rest of UserProvider's network/ +// socket dependencies so we only exercise its render-gating logic. +const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() })); + +vi.mock("@/features/user/hooks/use-current-user", () => ({ + default: h.useCurrentUser, +})); +vi.mock("@/features/auth/queries/auth-query.tsx", () => ({ + useCollabToken: () => ({ data: undefined }), +})); +vi.mock("@/features/websocket/use-query-subscription.ts", () => ({ + useQuerySubscription: () => {}, +})); +vi.mock("@/features/websocket/use-tree-socket.ts", () => ({ + useTreeSocket: () => {}, +})); +vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({ + useNotificationSocket: () => {}, +})); +vi.mock("@/main.tsx", () => ({ queryClient: {} })); +vi.mock("@/features/user/connect-resync.ts", () => ({ + makeConnectHandler: () => () => {}, +})); +vi.mock("socket.io-client", () => ({ + io: () => ({ on: vi.fn(), disconnect: vi.fn() }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + i18n: { + changeLanguage: vi.fn(), + language: "en-US", + resolvedLanguage: "en-US", + }, + }), +})); + +import { UserProvider } from "./user-provider"; + +const networkError = { message: "Network Error" }; // axios network error: no `response` + +function renderProvider() { + return render( + + + + +
app content
+
+
+
+
, + ); +} + +beforeEach(() => { + h.useCurrentUser.mockReset(); +}); + +describe("UserProvider offline render-gating", () => { + it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => { + // Offline reload: the persisted ['currentUser'] cache hydrates `data`, but + // the background POST /api/users/me refetch fails as a network error. + h.useCurrentUser.mockReturnValue({ + data: { + user: { id: "u1", locale: "en" }, + workspace: { id: "w1" }, + }, + isLoading: false, + error: networkError, + isError: true, + }); + + renderProvider(); + + // The cached app must render — NOT a blank fragment (#237/#238). + expect(screen.getByTestId("app-child")).toBeDefined(); + expect(screen.queryByText("You're offline")).toBeNull(); + }); + + it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => { + h.useCurrentUser.mockReturnValue({ + data: undefined, + isLoading: false, + error: networkError, + isError: true, + }); + + const { container } = renderProvider(); + + // Previously this returned `<>` — a blank white screen. Now it must show + // an explicit offline fallback. + expect(screen.getByText("You're offline")).toBeDefined(); + expect(screen.queryByTestId("app-child")).toBeNull(); + expect(container.textContent?.length).toBeGreaterThan(0); + }); + + it("renders the app normally on a successful currentUser load", () => { + h.useCurrentUser.mockReturnValue({ + data: { + user: { id: "u1", locale: "en" }, + workspace: { id: "w1" }, + }, + isLoading: false, + error: null, + isError: false, + }); + + renderProvider(); + expect(screen.getByTestId("app-child")).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index 5c29203c..74875a3c 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts"; import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; +import { OfflineFallback } from "@/features/offline/offline-fallback.tsx"; import { queryClient } from "@/main.tsx"; import { makeConnectHandler } from "@/features/user/connect-resync.ts"; @@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) { document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US"; }, [i18n.language, i18n.resolvedLanguage]); - if (isLoading) return <>; + // First load with no cached user yet: render nothing briefly while the + // persisted ['currentUser'] cache hydrates (avoids flashing the offline + // fallback before restore). Once we have a user we render the app even if a + // refetch is still in flight. + if (isLoading && !data) return <>; if (isError && error?.["response"]?.status === 404) { return ; } + // We have a (possibly cached/stale) user — render the app. Offline, the + // POST /api/users/me refetch fails as a network error, but the persisted/ + // hydrated user is enough to render the cached UI. Previously `if (error) + // return <>` blanked every authenticated route on an offline reload even + // though the cached data was present (#237/#238). + if (data) { + return <>{children}; + } + + // No user AND an error (offline cold boot of a page never warmed for offline, + // or no persisted cache to restore): show an explicit offline fallback rather + // than a blank white screen. if (error) { - return <>; + return ; } return <>{children}; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 30589a76..6ee4233a 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -26,6 +26,7 @@ import { queryPersister, shouldDehydrateOfflineQuery, } from "@/features/offline/query-persister"; +import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations"; import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt"; import { isCapacitorNativePlatform } from "@/pwa/is-capacitor"; import posthog from "posthog-js"; @@ -43,6 +44,12 @@ export const queryClient = new QueryClient({ }, }); +// Register default mutationFns for the offline-relevant structural mutations so +// a paused mutation restored from IndexedDB after an offline reload still has a +// mutationFn and is replayed by resumePausedMutations() on reconnect (instead +// of silently no-op'ing and dropping the offline create/move/comment). +registerOfflineMutationDefaults(queryClient); + if (isCloud() && isPostHogEnabled) { posthog.init(getPostHogKey(), { api_host: getPostHogHost(),