Skip to content

Commit 336f90f

Browse files
fix(desktop): new tab inherits current workspace + guard against malformed tab paths (#1198)
* fix(desktop): new tab inherits current workspace + guard against malformed tab paths Three layered fixes for the same root cause: tab URLs were being constructed without a workspace slug in some code paths, triggering NoAccessPage whenever the router interpreted the first segment as a (non-existent) workspace slug. ## Layer 1 — tab-bar "+" button now inherits current workspace The handler had a hardcoded `path = "/issues"` left over from before the slug URL refactor. Without a workspace prefix, the router saw `workspaceSlug = "issues"` and rendered NoAccessPage. Read `getCurrentSlug()` and build `/{slug}/issues` instead. Falls back to "/" (→ IndexRedirect) when there is no current workspace. This matches terminal/IDE new-tab semantics: new tab opens in the same workspace as the active tab, not in `wsList[0]`. ## Layer 2 — validateWorkspaceSlugs runs synchronously PR #1178 added startup validation of persisted tab slugs against the current workspace list, but ran it in a useEffect. useEffect fires AFTER commit, so the initial render would briefly show NoAccessPage on a stale slug before the effect reset the tab path. Moving the call into render phase eliminates that flash; zustand supports setState in render, and the validator is idempotent (early-returns if nothing changed) so this doesn't loop. ## Layer 3 — tab store rejects malformed paths at construction Any path whose first segment is a reserved slug (e.g. "/issues", "/login") clearly lacks a workspace prefix and is a caller bug. sanitizeTabPath catches these at makeTab time, rewrites to "/", and logs a console.warn naming the offending path so the bug can be fixed at source. Any future new-tab entry point that forgets the slug will not reach NoAccessPage. Net effect: NoAccessPage is reserved for its legitimate purpose — users navigating to URLs they genuinely don't have access to — and can no longer be triggered by system bugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * review: read new-tab workspace from active tab + unify sanitize + add tests Three follow-ups from self-review of PR #1198: 1. Resolve the current workspace from the active tab's path instead of from getCurrentSlug(). With N tabs mounted under <Activity>, every WorkspaceRouteLayout calls setCurrentWorkspace() in render — the singleton ends up holding "whichever tab rendered last", which is non-deterministic. activeTabId is the unambiguous source of truth for "which workspace is the user actually looking at right now". 2. Unify the persist merge's stale-path detection with sanitizeTabPath. The merge previously checked ROUTE_ICONS (dashboard segments only); sanitizeTabPath uses isReservedSlug (dashboard + auth + platform + RFC 2142 + hostname confusables). Same code path now, wider coverage, and one source of truth. 3. Add unit tests for sanitizeTabPath: root pass-through, global paths, valid workspace-scoped paths, malformed paths (reserved first segment) rejected with console.warn, and user slugs that happen to look path-like but aren't reserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6d6bc5a commit 336f90f

4 files changed

Lines changed: 111 additions & 24 deletions

File tree

apps/desktop/src/renderer/src/App.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,18 @@ function AppContent() {
8787
// Tabs survive across app restarts and account switches (persisted to
8888
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
8989
// reference a workspace the current user can't access — showing
90-
// NoAccessPage every time they open the app. Reset any such tab to `/`
91-
// so IndexRedirect picks a valid workspace. Runs on every workspace list
92-
// change (login, refetch, realtime workspace:deleted); idempotent.
93-
useEffect(() => {
94-
if (!workspaces) return;
90+
// NoAccessPage every time they open the app.
91+
//
92+
// Run synchronously in render phase rather than in useEffect so the first
93+
// render already sees validated tabs. useEffect runs AFTER commit, which
94+
// means the initial render would briefly show NoAccessPage before the
95+
// effect resets the tab. Zustand supports render-phase setState; the
96+
// validator is idempotent (exits early if nothing changed) so this
97+
// doesn't loop.
98+
if (workspaces) {
9599
const validSlugs = new Set(workspaces.map((w) => w.slug));
96100
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
97-
}, [workspaces]);
101+
}
98102
// null = undecided (pre-login or list hasn't settled yet)
99103
// true = session started with zero workspaces; next transition to >=1 triggers restart
100104
// false = session started with >=1 workspace, OR we've already restarted; skip

apps/desktop/src/renderer/src/components/tab-bar.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { CSS } from "@dnd-kit/utilities";
3131
import { cn } from "@multica/ui/lib/utils";
3232
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
33+
import { isGlobalPath, paths } from "@multica/core/paths";
3334

3435
const TAB_ICONS: Record<string, LucideIcon> = {
3536
Inbox,
@@ -124,10 +125,22 @@ function NewTabButton() {
124125
const setActiveTab = useTabStore((s) => s.setActiveTab);
125126

126127
const handleClick = () => {
127-
const path = "/issues";
128+
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
129+
// opens in the same context as the active one. Read the slug from the
130+
// active tab's path directly rather than from getCurrentSlug(), because
131+
// that singleton is "last tab to render" (non-deterministic with N tabs
132+
// mounted under <Activity>), while activeTabId is the unambiguous truth.
133+
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
134+
// tab is on a global route (e.g. /workspaces/new, /login).
135+
const { tabs, activeTabId } = useTabStore.getState();
136+
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
137+
let slug: string | null = null;
138+
if (activePath !== "/" && !isGlobalPath(activePath)) {
139+
slug = activePath.split("/").filter(Boolean)[0] ?? null;
140+
}
141+
const path = slug ? paths.workspace(slug).issues() : "/";
128142
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
129143
setActiveTab(tabId);
130-
// No navigate() — new tab's router starts at /issues automatically
131144
};
132145

133146
return (
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
// createTabRouter transitively pulls in route modules that expect a browser
4+
// router context. For pure-function tests we stub it out.
5+
vi.mock("../routes", () => ({
6+
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
7+
}));
8+
9+
import { sanitizeTabPath } from "./tab-store";
10+
11+
describe("sanitizeTabPath", () => {
12+
it("passes through root sentinel", () => {
13+
expect(sanitizeTabPath("/")).toBe("/");
14+
});
15+
16+
it("passes through global paths", () => {
17+
expect(sanitizeTabPath("/login")).toBe("/login");
18+
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
19+
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
20+
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
21+
});
22+
23+
it("passes through valid workspace-scoped paths", () => {
24+
expect(sanitizeTabPath("/acme/issues")).toBe("/acme/issues");
25+
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
26+
});
27+
28+
it("rejects paths whose first segment is a reserved slug", () => {
29+
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
30+
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
31+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
32+
expect(sanitizeTabPath("/issues")).toBe("/");
33+
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
34+
expect(sanitizeTabPath("/settings")).toBe("/");
35+
expect(warn).toHaveBeenCalled();
36+
warn.mockRestore();
37+
});
38+
39+
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
40+
// A workspace owner could legitimately pick "acme-issues" or
41+
// "project-x" as their slug — sanitize must not touch these.
42+
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
43+
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
44+
});
45+
});

apps/desktop/src/renderer/src/stores/tab-store.ts

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
33
import { arrayMove } from "@dnd-kit/sortable";
44
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
55
import { createSafeId } from "@multica/core/utils";
6-
import { isGlobalPath } from "@multica/core/paths";
6+
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
77
import type { DataRouter } from "react-router-dom";
88
import { createTabRouter } from "../routes";
99

@@ -104,13 +104,44 @@ function createId(): string {
104104
return createSafeId();
105105
}
106106

107+
/**
108+
* Defensive: catch tab paths that were constructed without a workspace slug
109+
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
110+
* paths would get matched as `workspaceSlug="issues"` by the router and
111+
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
112+
* a valid workspace).
113+
*
114+
* Passes through:
115+
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
116+
* - workspace-scoped paths whose first segment is not a reserved word
117+
*
118+
* Rejects (and rewrites to "/"):
119+
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
120+
* means the caller forgot to prefix the workspace. Logs a warning so the
121+
* buggy call site is easy to find.
122+
*/
123+
export function sanitizeTabPath(path: string): string {
124+
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
125+
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
126+
if (isReservedSlug(firstSegment)) {
127+
// eslint-disable-next-line no-console
128+
console.warn(
129+
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
130+
`caller likely forgot the workspace prefix. Falling back to "/".`,
131+
);
132+
return DEFAULT_PATH;
133+
}
134+
return path;
135+
}
136+
107137
function makeTab(path: string, title: string, icon: string): Tab {
138+
const safePath = sanitizeTabPath(path);
108139
return {
109140
id: createId(),
110-
path,
141+
path: safePath,
111142
title,
112143
icon,
113-
router: createTabRouter(path),
144+
router: createTabRouter(safePath),
114145
historyIndex: 0,
115146
historyLength: 1,
116147
};
@@ -239,19 +270,13 @@ export const useTabStore = create<TabStore>()(
239270
if (!persisted?.tabs?.length) return currentState;
240271

241272
const tabs: Tab[] = persisted.tabs.map((tab) => {
242-
// Migration: pre-refactor tab paths like "/issues/abc" lack a
243-
// workspace slug prefix. These would 404 in the new router.
244-
// Reset to "/" so IndexRedirect picks the right workspace.
245-
let path = tab.path;
246-
if (path !== "/" && !isGlobalPath(path)) {
247-
const segments = path.split("/").filter(Boolean);
248-
const firstSegment = segments[0] ?? "";
249-
// If the first segment IS a known route name (e.g. "issues",
250-
// "projects"), it's an old-format path missing the slug prefix.
251-
if (ROUTE_ICONS[firstSegment]) {
252-
path = "/";
253-
}
254-
}
273+
// Sanitize persisted paths against reserved-slug rules. Catches
274+
// both pre-refactor paths like "/issues/abc" (missing workspace
275+
// slug) and any other malformed paths that slipped past the
276+
// write-time guard. The defense across makeTab + merge + runtime
277+
// validate ensures stale or malformed paths never reach the
278+
// router.
279+
const path = sanitizeTabPath(tab.path);
255280
return {
256281
...tab,
257282
path,

0 commit comments

Comments
 (0)