Skip to content
File

Blob: src/client/components/workspace/view-provider.tsx

typescript320 lines
1import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
2import { api } from "@/client/lib/api";
3import { classifyFailure } from "@/client/lib/classify-failure";
4import { createRequestGuard } from "@/client/lib/request-guard";
5import { SESSION_MODES } from "@/client/lib/constants";
6import { type WorkspaceRouteState, isWorkspaceReady, hasWorkspaceIdentity } from "@/client/lib/workspace-route-model";
7import { useAuthStore } from "@/client/stores/auth-store";
8import { waitForWorkspaceLocalHydration } from "@/client/stores/bootstrap";
9import {
10 useWorkspaceReplicaStore,
11 selectReplicaBySlug,
12 selectWorkspaceByPageId,
13 selectWorkspaceReplica,
14 type WorkspaceAccessMode,
15} from "@/client/stores/workspace-replica";
16import { replicaCommands } from "@/client/stores/db/workspace-replica";
17import { useWorkspaceDirectoryStore, selectWorkspaceBySlug } from "@/client/stores/workspace-directory";
18import { directoryCommands } from "@/client/stores/db/workspace-directory";
19import { useWorkspaceNavigationStore, selectLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation";
20import { navigationCommands } from "@/client/stores/db/workspace-navigation";
21import { useOnline } from "@/client/hooks/use-online";
22import { WorkspaceViewCtx, type WorkspaceViewContext } from "./use-workspace-view";
23import type { Page, WorkspaceMember, WorkspaceMembershipSummary } from "@/shared/types";
24 
25function seedFromCache(workspaceSlug: string, pageId: string | null): WorkspaceRouteState {
26 const directory = useWorkspaceDirectoryStore.getState();
27 const replica = useWorkspaceReplicaStore.getState();
28 
29 const memberWs = selectWorkspaceBySlug(directory, workspaceSlug);
30 if (memberWs) {
31 const replicaRow = selectWorkspaceReplica(replica, memberWs.id);
32 if (replicaRow) {
33 return { phase: "ready", workspaceId: memberWs.id, accessMode: replicaRow.accessMode };
34 }
35 return { phase: "loading", workspaceId: memberWs.id };
36 }
37 
38 const bySlug = selectReplicaBySlug(replica, workspaceSlug);
39 if (bySlug) {
40 return { phase: "ready", workspaceId: bySlug.id, accessMode: bySlug.accessMode };
41 }
42 
43 if (pageId) {
44 const pageWorkspaceId = selectWorkspaceByPageId(replica, pageId);
45 if (pageWorkspaceId) {
46 const replicaRow = selectWorkspaceReplica(replica, pageWorkspaceId);
47 if (replicaRow) {
48 return { phase: "ready", workspaceId: pageWorkspaceId, accessMode: replicaRow.accessMode };
49 }
50 return { phase: "loading", workspaceId: pageWorkspaceId };
51 }
52 }
53 
54 return { phase: "loading", workspaceId: null };
55}
56 
57async function fetchWorkspaceData(
58 workspaceId: string,
59 accessMode: WorkspaceAccessMode,
60): Promise<{ pages: Page[]; members: WorkspaceMember[] }> {
61 if (accessMode === "shared") {
62 const pages = await api.pages.list(workspaceId);
63 return { pages, members: [] };
64 }
65 const [pages, members] = await Promise.all([api.pages.list(workspaceId), api.workspaces.members(workspaceId)]);
66 return { pages, members };
67}
68 
69interface WorkspaceViewProviderProps {
70 workspaceSlug: string;
71 pageId: string | null;
72 children: ReactNode;
73}
74 
75/**
76 * Resolves the workspace identity for `/$workspaceSlug` and `/$workspaceSlug/$pageId`.
77 *
78 * - Page routes (pageId present) use `api.pages.context(pageId)` first when
79 * online + authenticated. The slug is decorative; canonical correction
80 * redirects mismatched slugs to their authoritative form.
81 * - Shell routes and offline page routes use slug-first via
82 * `api.workspaces.list`, with a shared-downgrade probe via `api.pages.list`
83 * when the slug is no longer in member scope.
84 * - Once `route.phase === "ready"`, we do not re-resolve on page navigation
85 * within the same workspace (the active-page boundary handles per-page loads).
86 */
87export function WorkspaceViewProvider({ workspaceSlug, pageId, children }: WorkspaceViewProviderProps) {
88 const [route, setRoute] = useState<WorkspaceRouteState>(() => seedFromCache(workspaceSlug, pageId));
89 const [canonicalSlug, setCanonicalSlug] = useState<string | undefined>();
90 
91 const epochRef = useRef(0);
92 const activeRef = useRef(true);
93 
94 // Tracks data freshness for the revalidation-skip optimization below.
95 // Private to this provider; not surfaced on WorkspaceRouteState. Seeded as
96 // "cache" because a cache-seeded ready state is the only way initial mount
97 // can produce ready — cold-start produces loading.
98 const cacheStatusRef = useRef<"cache" | "live">("cache");
99 
100 const sessionMode = useAuthStore((s) => s.sessionMode);
101 const online = useOnline();
102 const previousNetworkStateRef = useRef({ online, sessionMode });
103 
104 const readRoute = useEffectEvent(() => route);
105 
106 // Last-visited writes are gated on membership-axis access (accessMode
107 // "member"). Shared-surface visits do not pollute the root-gateway redirect
108 // target or the per-workspace last-visited-page map.
109 const readyMemberWorkspaceId = route.phase === "ready" && route.accessMode === "member" ? route.workspaceId : null;
110 
111 useEffect(() => {
112 if (!readyMemberWorkspaceId) return;
113 const current = selectLastVisitedWorkspaceId(useWorkspaceNavigationStore.getState());
114 if (current !== readyMemberWorkspaceId) {
115 void navigationCommands.setLastVisitedWorkspaceId(readyMemberWorkspaceId);
116 }
117 }, [readyMemberWorkspaceId]);
118 
119 useEffect(() => {
120 if (!readyMemberWorkspaceId || !pageId) return;
121 void navigationCommands.setLastVisitedPage(readyMemberWorkspaceId, pageId);
122 }, [readyMemberWorkspaceId, pageId]);
123 
124 useEffect(() => {
125 activeRef.current = true;
126 return () => {
127 activeRef.current = false;
128 };
129 }, []);
130 
131 useEffect(() => {
132 const currentRoute = readRoute();
133 const regainedLiveSession =
134 online &&
135 sessionMode === SESSION_MODES.AUTHENTICATED &&
136 (!previousNetworkStateRef.current.online ||
137 previousNetworkStateRef.current.sessionMode !== SESSION_MODES.AUTHENTICATED);
138 previousNetworkStateRef.current = { online, sessionMode };
139 
140 // Only skip when this workspace has already been confirmed live.
141 // Cache-seeded routes still need one online revalidation pass.
142 if (!regainedLiveSession && isWorkspaceReady(currentRoute) && cacheStatusRef.current === "live") {
143 if (!online) return;
144 if (!pageId) return;
145 const replica = useWorkspaceReplicaStore.getState();
146 const cachedPages = replica.pagesByWorkspaceId.get(currentRoute.workspaceId) ?? [];
147 if (cachedPages.some((p) => p.id === pageId)) return;
148 }
149 
150 const request = createRequestGuard(epochRef, activeRef);
151 
152 async function resolve() {
153 // Settle any router-driven rehydrate before trusting the seed. In-app
154 // transitions from a non-local path (`/login`, `/s/$token`) can land
155 // here with empty projections; waiting lets us re-derive identity from
156 // the cache before fallback branches declare a terminal error.
157 await waitForWorkspaceLocalHydration();
158 if (!request.isCurrent()) return;
159 if (!isWorkspaceReady(readRoute())) {
160 setRoute(seedFromCache(workspaceSlug, pageId));
161 }
162 
163 // Page-route resolver: pages.context-first when online + authenticated.
164 // Bootstraps workspace identity from a single authoritative call,
165 // sidestepping slug->workspace resolution entirely.
166 if (pageId && online && sessionMode === SESSION_MODES.AUTHENTICATED) {
167 try {
168 const ctx = await api.pages.context(pageId);
169 if (!request.isCurrent()) return;
170 
171 const accessMode = ctx.viewer.access_mode as WorkspaceAccessMode;
172 const workspaceRole = ctx.viewer.workspace_role;
173 const { pages, members } = await fetchWorkspaceData(ctx.workspace.id, accessMode);
174 if (!request.isCurrent()) return;
175 
176 await replicaCommands.replaceWorkspace({
177 workspace: ctx.workspace,
178 accessMode,
179 workspaceRole,
180 pages,
181 members,
182 });
183 // `accessMode === "member"` implies membership row exists, which means
184 // `workspace_role` is non-null. `directoryCommands.upsert` requires role,
185 // so we derive the membership summary here.
186 if (accessMode === "member" && workspaceRole !== null) {
187 await directoryCommands.upsert({ ...ctx.workspace, role: workspaceRole });
188 }
189 // Last-visited writes are handled by the readyMemberWorkspaceId effect
190 // above so shared-surface visits do not pollute the cache.
191 
192 cacheStatusRef.current = "live";
193 setRoute({ phase: "ready", workspaceId: ctx.workspace.id, accessMode });
194 setCanonicalSlug(ctx.workspace.slug !== workspaceSlug ? ctx.workspace.slug : undefined);
195 } catch (err) {
196 if (!request.isCurrent()) return;
197 const failure = classifyFailure(err, { online: navigator.onLine });
198 if (failure === "forbidden" || failure === "not-found") {
199 setRoute({ phase: "error", errorKind: "not-found", message: "Page not found" });
200 } else {
201 setRoute((prev) => {
202 if (isWorkspaceReady(prev) || prev.phase === "degraded") return prev;
203 const cachedWsId = hasWorkspaceIdentity(prev) ? prev.workspaceId : null;
204 return cachedWsId
205 ? { phase: "degraded", workspaceId: cachedWsId, workspaceSlug, reason: "stale-shared" }
206 : { phase: "error", errorKind: "network", message: "Failed to load workspace" };
207 });
208 }
209 }
210 return;
211 }
212 
213 // Shell route or offline page route: slug-first.
214 let workspaces: WorkspaceMembershipSummary[];
215 try {
216 workspaces = await api.workspaces.list();
217 } catch {
218 if (!request.isCurrent()) return;
219 setRoute((prev) => {
220 if (isWorkspaceReady(prev) || prev.phase === "degraded") return prev;
221 return { phase: "error", errorKind: "network", message: "Failed to load workspace" };
222 });
223 return;
224 }
225 
226 if (!request.isCurrent()) return;
227 await directoryCommands.replaceAll(workspaces);
228 
229 const ws = workspaces.find((w) => w.slug === workspaceSlug);
230 if (ws) {
231 try {
232 const { pages, members } = await fetchWorkspaceData(ws.id, "member");
233 if (!request.isCurrent()) return;
234 // Guests stay on `accessMode: "member"` (they have a membership row),
235 // but carry `workspaceRole: "guest"` so role-aware affordances keep
236 // create/invite/AI hidden. This is the slug-first guest-lie fix.
237 await replicaCommands.replaceWorkspace({
238 workspace: ws,
239 accessMode: "member",
240 workspaceRole: ws.role,
241 pages,
242 members,
243 });
244 cacheStatusRef.current = "live";
245 setRoute({ phase: "ready", workspaceId: ws.id, accessMode: "member" });
246 } catch {
247 if (!request.isCurrent()) return;
248 const replica = useWorkspaceReplicaStore.getState();
249 const replicaRow = selectWorkspaceReplica(replica, ws.id);
250 if (replicaRow) {
251 cacheStatusRef.current = "cache";
252 setRoute({ phase: "ready", workspaceId: ws.id, accessMode: replicaRow.accessMode });
253 } else {
254 setRoute({ phase: "error", errorKind: "network", message: "Failed to load workspace" });
255 }
256 }
257 return;
258 }
259 
260 // Slug not in member list: shared-downgrade probe. Look up the cached
261 // replica directly — no ref needed since hydration already settled.
262 const cachedReplica = selectReplicaBySlug(useWorkspaceReplicaStore.getState(), workspaceSlug);
263 if (!cachedReplica) {
264 setRoute({ phase: "error", errorKind: "not-found", message: "Workspace not found" });
265 return;
266 }
267 const cachedWsId = cachedReplica.id;
268 try {
269 const pages = await api.pages.list(cachedWsId);
270 if (!request.isCurrent()) return;
271 if (pages.length === 0) {
272 setRoute({
273 phase: "degraded",
274 workspaceId: cachedWsId,
275 workspaceSlug,
276 reason: "stale-shared",
277 });
278 return;
279 }
280 await replicaCommands.replaceWorkspace({
281 workspace: cachedReplica.workspace,
282 accessMode: "shared",
283 workspaceRole: null,
284 pages,
285 members: [],
286 });
287 cacheStatusRef.current = "live";
288 setRoute({ phase: "ready", workspaceId: cachedWsId, accessMode: "shared" });
289 } catch (err) {
290 if (!request.isCurrent()) return;
291 const failure = classifyFailure(err, { online: navigator.onLine });
292 const definitive = failure === "forbidden" || failure === "not-found";
293 if (definitive) {
294 setRoute({
295 phase: "degraded",
296 workspaceId: cachedWsId,
297 workspaceSlug,
298 reason: "stale-shared",
299 });
300 } else {
301 setRoute((prev) => {
302 if (isWorkspaceReady(prev)) return prev;
303 cacheStatusRef.current = "cache";
304 return { phase: "ready", workspaceId: cachedWsId, accessMode: "shared" };
305 });
306 }
307 }
308 }
309 
310 resolve();
311 return () => {
312 request.cancel();
313 };
314 }, [workspaceSlug, pageId, online, sessionMode]);
315 
316 const contextValue = useMemo<WorkspaceViewContext>(() => ({ route, canonicalSlug }), [route, canonicalSlug]);
317 
318 return <WorkspaceViewCtx.Provider value={contextValue}>{children}</WorkspaceViewCtx.Provider>;
319}