Skip to content
File

Blob: src/client/components/active-page/provider.tsx

typescript401 lines
1import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
2import type YProvider from "y-partyserver/provider";
3import { api, toApiError } from "@/client/lib/api";
4import { classifyFailure } from "@/client/lib/classify-failure";
5import { docCache } from "@/client/lib/doc-cache-registry";
6import { getPageLoadTarget } from "@/client/lib/page-load-target";
7import { queryClient } from "@/client/lib/query-client";
8import { pageAncestorsQueryOptions } from "@/client/lib/queries/page-ancestors";
9import { createRequestGuard } from "@/client/lib/request-guard";
10import { reportClientError } from "@/client/lib/report-client-error";
11import { useAuthStore } from "@/client/stores/auth-store";
12import { useOnline } from "@/client/hooks/use-online";
13import type { PageAccessMode, WorkspaceAccessMode } from "@/client/stores/workspace-replica";
14import type { FailureKind } from "@/client/lib/classify-failure";
15import {
16 type ActivePageAccess,
17 type ActivePageInitialSnapshot,
18 type ActivePagePatch,
19 type ActivePageSnapshot,
20 type ActivePageState,
21 type ActivePageSurface,
22 accessFromCachedPage,
23 getPageLoadFailureAction,
24 needsRestrictedAncestors,
25} from "@/client/lib/active-page-model";
26import { useMaybeWorkspaceView } from "@/client/components/workspace/use-workspace-view";
27import { ActivePageContexts } from "./use-active-page";
28import type { GetPageResponse, Page, PageAncestor, WorkspaceRole } from "@/shared/types";
29 
30interface ActivePageProviderProps {
31 surface: ActivePageSurface;
32 workspaceId: string | null;
33 pageId: string;
34 accessMode: WorkspaceAccessMode | null;
35 role: WorkspaceRole | null;
36 cachedPageMeta: Page | null;
37 cachedAccess: PageAccessMode | null;
38 shareToken: string | null;
39 initialSnapshot?: ActivePageInitialSnapshot | null;
40 onLivePageLoaded?: (page: Page, access: ActivePageAccess) => void;
41 onEvict?: (pageId: string) => void;
42 children: ReactNode;
43}
44 
45function buildUnavailableState(failureKind: FailureKind, err: unknown): ActivePageState {
46 switch (failureKind) {
47 case "forbidden":
48 return {
49 kind: "unavailable",
50 reason: "gone",
51 message: "You no longer have access to this page.",
52 };
53 case "not-found":
54 return {
55 kind: "unavailable",
56 reason: "gone",
57 message: "This page is no longer available.",
58 };
59 case "network":
60 return {
61 kind: "unavailable",
62 reason: "error",
63 message: "Couldn't reach the server. Check your connection and try again.",
64 };
65 case "auth-ambiguous":
66 return {
67 kind: "unavailable",
68 reason: "error",
69 message: "Your session may have expired. Please refresh.",
70 };
71 case "server":
72 case "unknown":
73 default:
74 return {
75 kind: "unavailable",
76 reason: "error",
77 message: toApiError(err).message,
78 };
79 }
80}
81 
82function snapshotFromPage(
83 page: Pick<Page, "id" | "workspace_id" | "kind" | "title" | "icon" | "cover_url">,
84): ActivePageSnapshot {
85 return {
86 id: page.id,
87 workspaceId: page.workspace_id,
88 kind: page.kind,
89 title: page.title,
90 icon: page.icon,
91 coverUrl: page.cover_url,
92 };
93}
94 
95function accessFromLivePage(page: GetPageResponse): ActivePageAccess {
96 return { mode: page.can_edit ? "edit" : "view" };
97}
98 
99function resolveAncestorsState(
100 prev: ActivePageState,
101 pageId: string,
102 shouldLoadRestrictedAncestors: boolean,
103): Pick<Extract<ActivePageState, { kind: "ready" }>, "ancestors" | "ancestorsStatus"> {
104 if (prev.kind === "ready" && prev.snapshot.id === pageId) {
105 return {
106 ancestors: prev.ancestors,
107 ancestorsStatus: prev.ancestorsStatus,
108 };
109 }
110 
111 return {
112 ancestors: [],
113 ancestorsStatus: shouldLoadRestrictedAncestors ? "loading" : "ready",
114 };
115}
116 
117function buildReadyState(
118 snapshot: ActivePageSnapshot,
119 backing: "live" | "cache",
120 access: ActivePageAccess,
121 prev: ActivePageState,
122 shouldLoadRestrictedAncestors: boolean,
123): ActivePageState {
124 return {
125 kind: "ready",
126 backing,
127 snapshot,
128 access,
129 ...resolveAncestorsState(prev, snapshot.id, shouldLoadRestrictedAncestors),
130 };
131}
132 
133function initialSnapshotToReadyState(
134 initial: ActivePageInitialSnapshot,
135 pageId: string,
136 prev: ActivePageState,
137 shouldLoadRestrictedAncestors: boolean,
138): ActivePageState | null {
139 if (initial.snapshot.id !== pageId) return null;
140 return buildReadyState(initial.snapshot, "live", initial.access, prev, shouldLoadRestrictedAncestors);
141}
142 
143export function ActivePageProvider({
144 surface,
145 workspaceId,
146 pageId,
147 accessMode,
148 role,
149 cachedPageMeta,
150 cachedAccess,
151 shareToken,
152 initialSnapshot,
153 onLivePageLoaded,
154 onEvict,
155 children,
156}: ActivePageProviderProps) {
157 const online = useOnline();
158 const sessionMode = useAuthStore((s) => s.sessionMode);
159 const workspaceView = useMaybeWorkspaceView();
160 const shouldLoadRestrictedAncestors = needsRestrictedAncestors(accessMode, role);
161 
162 const pageLoadTarget = useMemo(
163 () =>
164 getPageLoadTarget({
165 surface,
166 workspaceId,
167 online,
168 sessionMode,
169 cachedPage: cachedPageMeta,
170 docCached: docCache.has(pageId),
171 route: workspaceView?.route ?? null,
172 }),
173 [surface, workspaceId, online, sessionMode, cachedPageMeta, pageId, workspaceView],
174 );
175 
176 const [state, setState] = useState<ActivePageState>(() => {
177 if (initialSnapshot) {
178 return (
179 initialSnapshotToReadyState(initialSnapshot, pageId, { kind: "loading" }, shouldLoadRestrictedAncestors) ?? {
180 kind: "loading",
181 }
182 );
183 }
184 return { kind: "loading" };
185 });
186 const [syncProvider, setSyncProvider] = useState<YProvider | null>(null);
187 const epochRef = useRef(0);
188 const activeRef = useRef(true);
189 const readCachedPage = useEffectEvent(() => cachedPageMeta);
190 const readCachedAccess = useEffectEvent(() => cachedAccess);
191 const readOnline = useEffectEvent(() => online);
192 const emitLivePageLoaded = useEffectEvent((page: Page, access: ActivePageAccess) => {
193 onLivePageLoaded?.(page, access);
194 });
195 const emitEvict = useEffectEvent((targetPageId: string) => {
196 onEvict?.(targetPageId);
197 });
198 
199 useEffect(() => {
200 activeRef.current = true;
201 return () => {
202 activeRef.current = false;
203 };
204 }, []);
205 
206 const patchPage = useCallback((updates: ActivePagePatch) => {
207 setState((prev) => {
208 if (prev.kind !== "ready") return prev;
209 return {
210 ...prev,
211 snapshot: {
212 ...prev.snapshot,
213 ...(updates.title !== undefined ? { title: updates.title } : {}),
214 ...(updates.icon !== undefined ? { icon: updates.icon } : {}),
215 ...(updates.coverUrl !== undefined ? { coverUrl: updates.coverUrl } : {}),
216 },
217 };
218 });
219 }, []);
220 
221 useEffect(() => {
222 if (pageLoadTarget === null) return;
223 const request = createRequestGuard(epochRef, activeRef);
224 
225 async function syncRestrictedAncestors(targetPageId: string, ancestorsPromise: Promise<PageAncestor[]> | null) {
226 if (!ancestorsPromise) return;
227 
228 try {
229 const ancestors = await ancestorsPromise;
230 if (!request.isCurrent()) return;
231 setState((prev) => {
232 if (prev.kind !== "ready" || prev.snapshot.id !== targetPageId) return prev;
233 return { ...prev, ancestors, ancestorsStatus: "ready" };
234 });
235 } catch {
236 if (!request.isCurrent()) return;
237 setState((prev) => {
238 if (prev.kind !== "ready" || prev.snapshot.id !== targetPageId) return prev;
239 if (prev.ancestorsStatus === "ready") return prev;
240 return { ...prev, ancestorsStatus: "ready" };
241 });
242 }
243 }
244 
245 async function load() {
246 const ancestorsPromise =
247 pageLoadTarget === "live" && shouldLoadRestrictedAncestors && workspaceId
248 ? queryClient.fetchQuery(pageAncestorsQueryOptions(workspaceId, pageId, shareToken))
249 : null;
250 
251 if (initialSnapshot && initialSnapshot.snapshot.id === pageId) {
252 setState(
253 (prev) => initialSnapshotToReadyState(initialSnapshot, pageId, prev, shouldLoadRestrictedAncestors) ?? prev,
254 );
255 await syncRestrictedAncestors(pageId, ancestorsPromise);
256 return;
257 }
258 
259 setState((prev) => (prev.kind === "ready" && prev.snapshot.id === pageId ? prev : { kind: "loading" }));
260 
261 if (pageLoadTarget === "cached-page") {
262 const cached = readCachedPage();
263 if (cached) {
264 const cachedAccessMode = accessFromCachedPage(readCachedAccess());
265 setState((prev) =>
266 buildReadyState(snapshotFromPage(cached), "cache", cachedAccessMode, prev, shouldLoadRestrictedAncestors),
267 );
268 await syncRestrictedAncestors(pageId, ancestorsPromise);
269 } else {
270 setState({
271 kind: "unavailable",
272 reason: "offline-miss",
273 message: "This page isn't available offline yet.",
274 });
275 }
276 return;
277 }
278 
279 if (pageLoadTarget === "offline-unavailable") {
280 setState((prev) =>
281 prev.kind === "ready" && prev.snapshot.id === pageId
282 ? prev
283 : {
284 kind: "unavailable",
285 reason: "offline-miss",
286 message: "This page isn't available offline yet.",
287 },
288 );
289 return;
290 }
291 
292 if (pageLoadTarget === "cache-unavailable") {
293 setState((prev) =>
294 prev.kind === "ready" && prev.snapshot.id === pageId
295 ? prev
296 : {
297 kind: "unavailable",
298 reason: "error",
299 message: "This page can't be loaded right now and isn't available in cache.",
300 },
301 );
302 return;
303 }
304 
305 if (!workspaceId) return;
306 
307 try {
308 const data = await api.pages.get(workspaceId, pageId, shareToken ?? undefined);
309 if (!request.isCurrent()) return;
310 
311 const liveAccess = accessFromLivePage(data);
312 emitLivePageLoaded(data.page, liveAccess);
313 
314 setState((prev) =>
315 buildReadyState(snapshotFromPage(data.page), "live", liveAccess, prev, shouldLoadRestrictedAncestors),
316 );
317 await syncRestrictedAncestors(pageId, ancestorsPromise);
318 } catch (err) {
319 if (!request.isCurrent()) return;
320 
321 const currentOnline = readOnline();
322 const failureKind = classifyFailure(err, { online: currentOnline });
323 const sessionMode = useAuthStore.getState().sessionMode;
324 const action = getPageLoadFailureAction(failureKind, currentOnline, sessionMode, surface);
325 
326 if (action === "evict") {
327 emitEvict(pageId);
328 docCache.remove(pageId);
329 setState({
330 kind: "unavailable",
331 reason: "gone",
332 message: "You no longer have access to this page.",
333 });
334 return;
335 }
336 
337 if (action === "cache-fallback") {
338 const cached = readCachedPage();
339 if (cached) {
340 if (docCache.has(pageId)) {
341 const cachedAccessMode = accessFromCachedPage(readCachedAccess());
342 setState((prev) =>
343 buildReadyState(
344 snapshotFromPage(cached),
345 "cache",
346 cachedAccessMode,
347 prev,
348 shouldLoadRestrictedAncestors,
349 ),
350 );
351 await syncRestrictedAncestors(pageId, ancestorsPromise);
352 } else {
353 setState({
354 kind: "unavailable",
355 reason: "offline-miss",
356 message: "This page isn't available offline yet.",
357 });
358 }
359 } else {
360 setState({
361 kind: "unavailable",
362 reason: "offline-miss",
363 message: "This page isn't available offline yet.",
364 });
365 }
366 return;
367 }
368 
369 reportClientError({
370 source: surface === "canonical" ? "page.load" : "shared-page.load",
371 error: err,
372 context: {
373 workspaceId,
374 pageId,
375 online: currentOnline,
376 sessionMode,
377 failureKind,
378 },
379 });
380 setState(buildUnavailableState(failureKind, err));
381 }
382 }
383 
384 load();
385 return () => {
386 request.cancel();
387 };
388 }, [surface, pageLoadTarget, workspaceId, pageId, shareToken, initialSnapshot, shouldLoadRestrictedAncestors]);
389 
390 const syncValue = useMemo(() => ({ syncProvider, setSyncProvider }), [syncProvider]);
391 const actionsValue = useMemo(() => ({ patchPage }), [patchPage]);
392 
393 return (
394 <ActivePageContexts.State.Provider value={state}>
395 <ActivePageContexts.Sync.Provider value={syncValue}>
396 <ActivePageContexts.Actions.Provider value={actionsValue}>{children}</ActivePageContexts.Actions.Provider>
397 </ActivePageContexts.Sync.Provider>
398 </ActivePageContexts.State.Provider>
399 );
400}