open-design/apps/web/src/router.ts
Bassiiiii 0c4b7e50be
fix(web/router): defer popstate dispatch to microtask (#2490)
* fix(web/router): defer popstate dispatch to microtask

navigate() previously dispatched a synchronous popstate event after
mutating window.history, which caused React 18 to emit:

  Cannot update a component (Router) while rendering a different
  component (App). To locate the bad setState() call inside App,
  follow the stack trace as described in
  https://react.dev/link/setstate-in-render

This happens whenever a caller invokes navigate() from inside a
useState updater (e.g. App.tsx:479 routing first-run users through
the onboarding panel from inside the setConfig() update). The
synchronous popstate dispatch reaches useRoute() subscribers which
then call setRoute() while the parent component is still rendering.

Defer the popstate dispatch to a microtask. The window.history call
itself stays synchronous so the URL bar updates immediately; only
subscriber updates are pushed past the current render commit, which
removes the warning without changing observable behaviour for any
existing caller.

* fix(web/router): cover deferred navigation timing

---------

Co-authored-by: Visionboost <contact@visionboost.fr>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-05-29 09:37:55 +00:00

181 lines
6.8 KiB
TypeScript

// Tiny URL router. We avoid pulling in react-router for two reasons:
// the surface area we need is small (three routes, plain pushState), and
// we want a single source of truth for "what file is open" — encoding
// that in the URL is the simplest way to make it deep-linkable.
import { useSyncExternalStore } from 'react';
// Entry-shell sub-views. The home/project landing renders one of three
// columns and each sub-view now owns a top-level path so the browser
// back/forward buttons work, deep links are shareable, and per-tab
// state isn't trapped behind a `useState` boundary.
export type EntryHomeView =
| 'home'
| 'onboarding'
| 'projects'
| 'tasks'
| 'plugins'
| 'design-systems'
| 'integrations';
export type Route =
| { kind: 'home'; view: EntryHomeView }
| { kind: 'design-system-create' }
| { kind: 'design-system-detail'; designSystemId: string }
| {
kind: 'project';
projectId: string;
/**
* Deep-link to a specific conversation inside the project. When
* present, the project view picks this conversation as the active
* one instead of defaulting to `list[0]`. Falls back to the
* default picker when the routed conversation no longer exists.
* Added for issue #1505 (Routines history → specific conversation).
*/
conversationId?: string | null;
fileName: string | null;
}
| { kind: 'marketplace' }
| { kind: 'marketplace-detail'; pluginId: string };
export function parseRoute(pathname: string): Route {
const parts = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (parts.length === 0) return { kind: 'home', view: 'home' };
if (parts[0] === 'onboarding') {
return { kind: 'home', view: 'onboarding' };
}
if (parts[0] === 'projects') {
if (parts[1]) {
const projectId = decodeURIComponent(parts[1]);
// /projects/:id/conversations/:cid[/files/...]
if (parts[2] === 'conversations' && parts[3]) {
const conversationId = decodeURIComponent(parts[3]);
if (parts[4] === 'files' && parts[5]) {
return {
kind: 'project',
projectId,
conversationId,
fileName: decodeURIComponent(parts.slice(5).join('/')),
};
}
return { kind: 'project', projectId, conversationId, fileName: null };
}
// /projects/:id/files/...
if (parts[2] === 'files' && parts[3]) {
return {
kind: 'project',
projectId,
conversationId: null,
fileName: decodeURIComponent(parts.slice(3).join('/')),
};
}
return { kind: 'project', projectId, conversationId: null, fileName: null };
}
return { kind: 'home', view: 'projects' };
}
if (parts[0] === 'design-systems') {
if (parts[1] === 'create') {
return { kind: 'design-system-create' };
}
if (parts[1]) {
return { kind: 'design-system-detail', designSystemId: decodeURIComponent(parts[1]) };
}
return { kind: 'home', view: 'design-systems' };
}
if (parts[0] === 'automations' || parts[0] === 'tasks') {
return { kind: 'home', view: 'tasks' };
}
if (parts[0] === 'plugins' && !parts[1]) {
return { kind: 'home', view: 'plugins' };
}
if (parts[0] === 'integrations') {
return { kind: 'home', view: 'integrations' };
}
// Phase 2B / spec §11.6 — marketplace deep UI routes. Two paths:
// /marketplace → catalog grid (MarketplaceView)
// /marketplace/<pluginId> → detail page (PluginDetailView)
// Aliases to /plugins remain reserved for the public site (spec §13);
// in-app we keep /marketplace canonical.
if (parts[0] === 'marketplace' || parts[0] === 'plugins') {
if (parts[1]) {
return { kind: 'marketplace-detail', pluginId: decodeURIComponent(parts[1]) };
}
return { kind: 'marketplace' };
}
return { kind: 'home', view: 'home' };
}
export function buildPath(route: Route): string {
if (route.kind === 'home') {
if (route.view === 'onboarding') return '/onboarding';
if (route.view === 'projects') return '/projects';
if (route.view === 'tasks') return '/automations';
if (route.view === 'plugins') return '/plugins';
if (route.view === 'design-systems') return '/design-systems';
if (route.view === 'integrations') return '/integrations';
return '/';
}
if (route.kind === 'marketplace') return '/marketplace';
if (route.kind === 'marketplace-detail') return `/marketplace/${encodeURIComponent(route.pluginId)}`;
if (route.kind === 'design-system-create') return '/design-systems/create';
if (route.kind === 'design-system-detail') {
return `/design-systems/${encodeURIComponent(route.designSystemId)}`;
}
const id = encodeURIComponent(route.projectId);
const file = route.fileName
? route.fileName.split('/').map((s) => encodeURIComponent(s)).join('/')
: null;
if (route.conversationId) {
const cid = encodeURIComponent(route.conversationId);
return file
? `/projects/${id}/conversations/${cid}/files/${file}`
: `/projects/${id}/conversations/${cid}`;
}
return file ? `/projects/${id}/files/${file}` : `/projects/${id}`;
}
// Centralized navigation. Components call this instead of mutating
// `window.location` directly so we can fan the change out to any
// `useRoute()` subscriber via a custom event.
//
// The `popstate` dispatch is deferred to a microtask so that callers
// can safely invoke `navigate()` from inside a `useState` updater or
// during a render commit phase without triggering React's
// "Cannot update a component while rendering a different component"
// warning. The `history` API call itself stays synchronous so the URL
// bar updates immediately; only the `useRoute()` subscriber updates
// are deferred past the current render.
export function navigate(route: Route, opts: { replace?: boolean } = {}): void {
const target = buildPath(route);
const current = window.location.pathname;
if (target === current) return;
if (opts.replace) {
window.history.replaceState(null, '', target);
} else {
window.history.pushState(null, '', target);
}
queueMicrotask(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
let cachedPathname: string | null = null;
let cachedRoute: Route | null = null;
function getRouteSnapshot(): Route {
const pathname = window.location.pathname;
if (cachedPathname !== pathname || cachedRoute === null) {
cachedPathname = pathname;
cachedRoute = parseRoute(pathname);
}
return cachedRoute;
}
function subscribeToRouteChanges(onStoreChange: () => void): () => void {
window.addEventListener('popstate', onStoreChange);
return () => window.removeEventListener('popstate', onStoreChange);
}
export function useRoute(): Route {
return useSyncExternalStore(subscribeToRouteChanges, getRouteSnapshot, getRouteSnapshot);
}