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 <Layout> 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) <noreply@anthropic.com>
This commit is contained in:
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
43
apps/client/src/features/auth/queries/auth-query.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<any, Error> {
|
||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||
//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);
|
||||
|
||||
@@ -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<IComment, Error, Partial<IComment>>({
|
||||
// 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(
|
||||
|
||||
@@ -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<MakePageAvailableOfflineResult> {
|
||||
const failed: string[] = [];
|
||||
|
||||
// Warm the current user (['currentUser']) so the auth-gated <Layout> 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),
|
||||
|
||||
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
45
apps/client/src/features/offline/offline-fallback.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("You're offline")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Container size="sm" py={80}>
|
||||
<Stack align="center" gap="md">
|
||||
<Title order={2} ta="center">
|
||||
{t("You're offline")}
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center">
|
||||
{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.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||
{t("Retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
114
apps/client/src/features/offline/offline-mutations.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
64
apps/client/src/features/offline/offline-mutations.ts
Normal file
@@ -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<IPageInput>) => 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<IComment>) => createComment(data),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 <Layout> 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<string>([
|
||||
"pages",
|
||||
"sidebar-pages",
|
||||
@@ -40,6 +47,7 @@ export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||
"space",
|
||||
"spaces",
|
||||
"recent-changes",
|
||||
"currentUser",
|
||||
]);
|
||||
|
||||
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||
|
||||
@@ -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<IPage, Error, Partial<IPageInput>>({
|
||||
// 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<void, Error, IMovePage>({
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
118
apps/client/src/features/user/user-provider.test.tsx
Normal file
@@ -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(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter>
|
||||
<MantineProvider>
|
||||
<UserProvider>
|
||||
<div data-testid="app-child">app content</div>
|
||||
</UserProvider>
|
||||
</MantineProvider>
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 <Error404 />;
|
||||
}
|
||||
|
||||
// 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 <OfflineFallback />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user