mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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>
181 lines
6.8 KiB
TypeScript
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);
|
|
}
|