Files
multica/packages/views/layout/use-dashboard-guard.ts
YYClaw 2e5e3a7189 fix(core): stop leaking recent issues across workspaces (#2403)
* fix(core): namespace recent-issues by workspace id in state

The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.

Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.

Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).

Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.

* fix(core): fail closed on null slug in workspace-aware storage

createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.

Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.

---------

Co-authored-by: YYClaw <yyclaw0@gmail.com>
2026-05-11 16:56:54 +08:00

79 lines
2.8 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigationStore } from "@multica/core/navigation";
import { useAuthStore } from "@multica/core/auth";
import {
paths,
resolvePostAuthDestination,
useCurrentWorkspace,
useHasOnboarded,
} from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace";
import { useRecentIssuesStore } from "@multica/core/issues/stores";
import { useNavigation } from "../navigation";
/**
* Auth + workspace gate for the dashboard.
*
* Redirect logic:
* - Auth still loading → wait
* - Not logged in → /login
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
* - Logged in but URL slug doesn't resolve to any workspace →
* `resolvePostAuthDestination(list, hasOnboarded)`:
* • un-onboarded → /onboarding
* • onboarded with workspaces → first workspace
* • onboarded with zero workspaces → /workspaces/new
*
* The "un-onboarded but in workspace" state is now physically impossible:
* CreateWorkspace and AcceptInvitation both atomically set `onboarded_at`
* inside the same transaction that inserts the `member` row.
* Existing dirty rows from PR #1868 are cleaned by migration 065.
*
* We read the workspace list query state directly (rather than relying on
* useCurrentWorkspace's null return) so we can distinguish "list loading"
* from "slug not found". Otherwise users could see a transient redirect
* before their workspace list arrives.
*/
export function useDashboardGuard() {
const { pathname, replace } = useNavigation();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useCurrentWorkspace();
const hasOnboarded = useHasOnboarded();
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (isLoading) return;
if (!user) {
replace(paths.login());
return;
}
if (!workspaceListFetched) return;
if (!workspace) {
replace(resolvePostAuthDestination(workspaces, hasOnboarded));
}
}, [user, isLoading, workspaceListFetched, workspace, workspaces, hasOnboarded, replace]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);
}, [pathname]);
// Drop recent-issues buckets for workspaces the user no longer belongs to.
// Runs once the workspace list resolves, and again whenever membership
// changes (workspace deleted, user kicked, user left).
useEffect(() => {
if (!workspaceListFetched) return;
useRecentIssuesStore
.getState()
.pruneWorkspaces(workspaces.map((w) => w.id));
}, [workspaceListFetched, workspaces]);
return { user, isLoading, workspace };
}