Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
d067110849 fix(core): collapse workspace rehydrate side effect into setCurrentWorkspace
Problem
-------
On desktop, creating a new tab triggered thousands of chat-store
rehydration logs per second (sustained for seconds). Same session,
same workspace — nothing actually changed. `pnpm test` was clean; the
bug only manifests at runtime with React 19 Activity + multi-tab.

Root cause
----------
Every tab's WorkspaceRouteLayout kept its own `syncedSlugRef` to decide
"did slug change since last sync". That model assumes one layout
instance equals one workspace context — true on web, false on desktop
where N tabs each mount their own layout. Activity remounts +
tab-router-sync stirring the tab store caused per-layout refs to drift
out of agreement with the module-level truth, so each ref independently
called `rehydrateAllWorkspaceStores()`. The existing microtask dedup
only coalesced same-tick calls; successive ticks each scheduled another
iteration through every registered rehydrate fn.

Fix
---
Move the "did slug actually change?" decision to where the truth lives:
inside `setCurrentWorkspace` itself. The singleton now:
 - Returns immediately when the slug is already current (idempotent).
 - Fires slug subscribers + persist rehydrate as internal side effects
   when (and only when) the slug transitions.

Layouts are simplified to "feed the URL slug in"; they no longer
maintain a ref guard or call rehydrate explicitly. N tabs feeding the
same slug is naturally a no-op after the first — the model no longer
depends on "one layout instance" as an implicit invariant.

Also hardens the original render-time race that motivated the v2
refactor: both layouts now gate on `!listFetched || !workspace` so
`useWorkspaceId()` in descendants is guaranteed non-null.

Public API
----------
`rehydrateAllWorkspaceStores` removed from `@multica/core/platform`
exports — it's now purely an internal effect of `setCurrentWorkspace`.
The function itself is deleted; the rehydrate loop lives inline in
`setCurrentWorkspace`.

Tests
-----
Four new tests covering the new semantics: single rehydrate on mount,
same-slug noop across repeat calls, real workspace switch fires again,
logout → re-entry into same workspace fires again.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:34:50 +08:00
5 changed files with 145 additions and 74 deletions

View File

@@ -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 resolvedbounce 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}>

View File

@@ -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}>

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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.