File
Blob: src/client/components/workspace/view-provider.tsx
| 1 | import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import { api } from "@/client/lib/api"; |
| 3 | import { classifyFailure } from "@/client/lib/classify-failure"; |
| 4 | import { createRequestGuard } from "@/client/lib/request-guard"; |
| 5 | import { SESSION_MODES } from "@/client/lib/constants"; |
| 6 | import { type WorkspaceRouteState, isWorkspaceReady, hasWorkspaceIdentity } from "@/client/lib/workspace-route-model"; |
| 7 | import { useAuthStore } from "@/client/stores/auth-store"; |
| 8 | import { waitForWorkspaceLocalHydration } from "@/client/stores/bootstrap"; |
| 9 | import { |
| 10 | useWorkspaceReplicaStore, |
| 11 | selectReplicaBySlug, |
| 12 | selectWorkspaceByPageId, |
| 13 | selectWorkspaceReplica, |
| 14 | type WorkspaceAccessMode, |
| 15 | } from "@/client/stores/workspace-replica"; |
| 16 | import { replicaCommands } from "@/client/stores/db/workspace-replica"; |
| 17 | import { useWorkspaceDirectoryStore, selectWorkspaceBySlug } from "@/client/stores/workspace-directory"; |
| 18 | import { directoryCommands } from "@/client/stores/db/workspace-directory"; |
| 19 | import { useWorkspaceNavigationStore, selectLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation"; |
| 20 | import { navigationCommands } from "@/client/stores/db/workspace-navigation"; |
| 21 | import { useOnline } from "@/client/hooks/use-online"; |
| 22 | import { WorkspaceViewCtx, type WorkspaceViewContext } from "./use-workspace-view"; |
| 23 | import type { Page, WorkspaceMember, WorkspaceMembershipSummary } from "@/shared/types"; |
| 24 | |
| 25 | function 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 | |
| 57 | async 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 | |
| 69 | interface 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 | */ |
| 87 | export 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 | } |