mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
ui/comment
...
fix/worksp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d067110849 |
@@ -1,21 +1,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
*
|
||||
* Reads :workspaceSlug from react-router params, resolves it to a Workspace
|
||||
* object via the React Query list cache, and syncs the URL-derived workspace
|
||||
* into the platform singleton (slug + UUID). Children (DashboardGuard +
|
||||
* dashboard layout) handle auth check, loading, and workspace-not-found.
|
||||
* Resolves the URL slug → workspace UUID via the React Query list cache
|
||||
* (seeded by AuthInitializer). Children do not render until the workspace
|
||||
* is fully resolved — useWorkspaceId() inside child pages is therefore
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we redirect to `/` so IndexRedirect can pick the first valid workspace
|
||||
* (more forgiving than bouncing to onboarding, which is only right for
|
||||
* zero-workspace users).
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -28,29 +31,42 @@ export function WorkspaceRouteLayout() {
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
// Render-phase sync (same pattern as web layout).
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && workspaceSlug && syncedSlugRef.current !== workspaceSlug) {
|
||||
// Feed the URL slug into the platform singleton so the API client's
|
||||
// X-Workspace-Slug header and persist namespace follow the active tab.
|
||||
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
|
||||
// every render (matters on desktop, where N tabs each mount their own
|
||||
// layout). Rehydrate is the singleton's internal side effect.
|
||||
if (workspace && workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
// Double-write legacy localStorage key for rollback compatibility — see
|
||||
// apps/web/app/[workspaceSlug]/layout.tsx for the full rationale.
|
||||
}
|
||||
|
||||
// Double-write legacy localStorage key for rollback compatibility — a
|
||||
// pre-refactor build reads it to pick the initial workspace. Placed in
|
||||
// an effect so repeated renders don't hammer localStorage.
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
try {
|
||||
localStorage.setItem("multica_workspace_id", workspace.id);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
}, [workspace]);
|
||||
|
||||
// Slug doesn't resolve → onboarding. Skip when user is null.
|
||||
// Slug can't be resolved → bounce to `/` (IndexRedirect picks first
|
||||
// valid workspace; falls to onboarding only if the list is truly empty).
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) navigate(paths.onboarding(), { replace: true });
|
||||
if (listFetched && !workspace) navigate(paths.root(), { replace: true });
|
||||
}, [user, listFetched, workspace, navigate]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the workspace list hasn't populated or the slug is
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useRef } from "react";
|
||||
import { use, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
setCurrentWorkspace,
|
||||
rehydrateAllWorkspaceStores,
|
||||
} from "@multica/core/platform";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
|
||||
export default function WorkspaceLayout({
|
||||
@@ -30,15 +27,12 @@ export default function WorkspaceLayout({
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Render-phase sync: set the current workspace slug + UUID into the
|
||||
// platform singleton BEFORE children render. This ensures the first
|
||||
// child query's X-Workspace-Slug header is already correct.
|
||||
// The ref guard prevents re-running on every render.
|
||||
const syncedSlugRef = useRef<string | null>(null);
|
||||
if (workspace && syncedSlugRef.current !== workspaceSlug) {
|
||||
// Render-phase sync: feed the URL slug into the platform singleton so
|
||||
// the first child query's X-Workspace-Slug header is already correct.
|
||||
// setCurrentWorkspace self-dedupes + runs rehydrate as a side effect;
|
||||
// safe to call on every render.
|
||||
if (workspace) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
syncedSlugRef.current = workspaceSlug;
|
||||
}
|
||||
|
||||
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
|
||||
@@ -60,16 +54,20 @@ export default function WorkspaceLayout({
|
||||
}
|
||||
}, [workspace, workspaceSlug]);
|
||||
|
||||
// Slug doesn't match any workspace the user has access to → onboarding.
|
||||
// Wait for the list query to settle so we don't bounce on first render.
|
||||
// Skip when user is null — DashboardGuard handles the /login redirect.
|
||||
// Slug doesn't match any workspace the user has access to → bounce to `/`
|
||||
// and let the root IndexRedirect pick the first valid workspace (falls to
|
||||
// onboarding only when the list is truly empty).
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (listFetched && !workspace) router.replace(paths.onboarding());
|
||||
if (listFetched && !workspace) router.replace(paths.root());
|
||||
}, [user, listFetched, workspace, router]);
|
||||
|
||||
// Auth still loading → render nothing (let DashboardGuard show its loader).
|
||||
if (isAuthLoading) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the list hasn't populated or the slug is unknown — gating
|
||||
// here makes that invariant hold for every descendant.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -3,5 +3,5 @@ export type { CoreProviderProps } from "./types";
|
||||
export { AuthInitializer } from "./auth-initializer";
|
||||
export { defaultStorage } from "./storage";
|
||||
export { createPersistStorage } from "./persist-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration, rehydrateAllWorkspaceStores } from "./workspace-storage";
|
||||
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
|
||||
export { clearWorkspaceStorage } from "./storage-cleanup";
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { createWorkspaceAwareStorage, setCurrentWorkspace } from "./workspace-storage";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
setCurrentWorkspace,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "./workspace-storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
|
||||
function mockAdapter(): StorageAdapter {
|
||||
@@ -59,3 +63,57 @@ describe("workspace-aware storage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("draft:dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCurrentWorkspace — rehydrate side effect", () => {
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
it("runs registered fns once when slug changes", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is a no-op when slug is unchanged — repeat calls with same slug skip the side effect", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("runs again on real workspace switch", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace("team-b", "ws_b");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("runs again after logout → re-entry into same workspace", async () => {
|
||||
const fn = vi.fn();
|
||||
registerForWorkspaceRehydration(fn);
|
||||
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
setCurrentWorkspace(null, null);
|
||||
await flush();
|
||||
setCurrentWorkspace("team-a", "ws_a");
|
||||
await flush();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,25 +14,35 @@ let _pendingNotify = false;
|
||||
let _pendingRehydrate = false;
|
||||
|
||||
/**
|
||||
* Set both the current workspace slug and UUID at once.
|
||||
* Called by the workspace layout's render-phase ref guard.
|
||||
* Notifies slug subscribers (e.g. WSProvider via useSyncExternalStore).
|
||||
* Update the current workspace identity. This is the single source of truth
|
||||
* for "which workspace is active"; everything downstream (WS connection,
|
||||
* persist namespace, cache-key derivation) follows from here.
|
||||
*
|
||||
* If the slug actually changed, two side effects fire:
|
||||
* 1. Subscribers are notified (e.g. WSProvider reconnects).
|
||||
* 2. All registered persist stores rehydrate from the new slug's namespace.
|
||||
*
|
||||
* Both side effects are idempotent on slug-equality: repeat calls with the
|
||||
* same slug are a pure no-op. This matters on desktop, where N tabs each
|
||||
* mount their own WorkspaceRouteLayout and each one naively tries to sync;
|
||||
* only the first call for a given slug does real work.
|
||||
*
|
||||
* Both side effects are deferred to a microtask because zustand persist
|
||||
* rehydrate + subscriber notifications both end up calling setState(), and
|
||||
* React 19 forbids "cross-component updates during render".
|
||||
*/
|
||||
export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
|
||||
const slugChanged = _currentSlug !== slug;
|
||||
if (_currentSlug === slug) {
|
||||
// Slug unchanged: nothing to rehydrate, nothing to notify. Accept a
|
||||
// (possibly) updated wsId for consumers that read the UUID mirror.
|
||||
_currentWsId = wsId;
|
||||
return;
|
||||
}
|
||||
_currentSlug = slug;
|
||||
_currentWsId = wsId;
|
||||
if (slugChanged && !_pendingNotify) {
|
||||
|
||||
if (!_pendingNotify) {
|
||||
_pendingNotify = true;
|
||||
// Defer and deduplicate subscriber notifications:
|
||||
// 1. Defer: avoids "cannot update component B while rendering A"
|
||||
// (React 19 render-phase restriction).
|
||||
// 2. Deduplicate: rapid A→B switches only notify once with the
|
||||
// final slug, avoiding a wasted WS connect+disconnect cycle.
|
||||
// The module vars are already updated synchronously above, so
|
||||
// authHeaders() and getCurrentSlug() return the correct value
|
||||
// immediately — subscribers are only for async consumers like
|
||||
// WSProvider that need to reconnect the WebSocket.
|
||||
queueMicrotask(() => {
|
||||
_pendingNotify = false;
|
||||
const current = _currentSlug;
|
||||
@@ -41,6 +51,16 @@ export function setCurrentWorkspace(slug: string | null, wsId: string | null) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!_pendingRehydrate) {
|
||||
_pendingRehydrate = true;
|
||||
queueMicrotask(() => {
|
||||
_pendingRehydrate = false;
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Current workspace slug (from URL). */
|
||||
@@ -71,27 +91,6 @@ export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
_rehydrateFns.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate all registered workspace-scoped persist stores from the new
|
||||
* namespace. Deferred to a microtask + deduplicated for the same reason
|
||||
* as slug subscriber notification: Zustand persist rehydrate synchronously
|
||||
* setState()s the store, which schedules updates on any component
|
||||
* subscribed to that store. Calling this from a component's render phase
|
||||
* would violate React 19's "no cross-component updates during render"
|
||||
* rule. Persist stores can tolerate one microtask of staleness — they're
|
||||
* UI preferences, not security-critical state.
|
||||
*/
|
||||
export function rehydrateAllWorkspaceStores() {
|
||||
if (_pendingRehydrate) return;
|
||||
_pendingRehydrate = true;
|
||||
queueMicrotask(() => {
|
||||
_pendingRehydrate = false;
|
||||
for (const fn of _rehydrateFns) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace slug.
|
||||
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
|
||||
|
||||
Reference in New Issue
Block a user