fix(routing): split stack detail URL from compose editor URL (#1605)

Sidebar opens /stacks/:name (anatomy). Monaco uses /compose|/env|/files.
Refresh of a detail URL no longer opens the editor, and editor deep links
keep a hydration shell instead of flashing the dashboard.
This commit is contained in:
Anso
2026-07-09 12:05:36 -04:00
committed by GitHub
parent 7517a4f49c
commit 296ddff2a0
11 changed files with 216 additions and 28 deletions
@@ -16,6 +16,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import type { SecurityTab, FleetTab } from '@/lib/events';
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
// Paid-tier views are loaded on demand. Their internal PaidGate /
// CapabilityGate wrappers render
@@ -101,6 +102,8 @@ export interface ViewRouterProps {
// not on every parent render that lands on a different view.
renderEditor: () => ReactNode;
stackUpdates: Record<string, StackUpdateInfo>;
urlHydratingStack: string | null;
isFileLoading: boolean;
}
export function ViewRouter({
@@ -131,6 +134,8 @@ export function ViewRouter({
onFleetActiveTabChange,
renderEditor,
stackUpdates,
urlHydratingStack,
isFileLoading,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
if (activeView === 'settings') {
@@ -175,12 +180,16 @@ export function ViewRouter({
</PaidGate>
);
}
// Fall-through: when activeView === 'editor' but selectedFile is
// null or the stack is still loading, drop through to the default
// HomeDashboard render below. This matches the pre-extraction
// behavior of the conditional ternary chain in EditorLayout.tsx.
if (!isLoading && selectedFile && activeView === 'editor') {
return renderEditor();
// Stack workspace: keep a loading shell while the stack URL hydrates.
// Never fall through to HomeDashboard for editor deep links (refresh flash).
if (activeView === 'editor') {
if (selectedFile) {
return renderEditor();
}
const awaitingStack = urlHydratingStack != null || isFileLoading || isStackEditorDeepLink();
if (awaitingStack || isLoading) {
return <ViewSkeleton />;
}
}
if (activeView === 'global-observability') {
return (
@@ -1248,6 +1248,10 @@ export function useStackActions(options: UseStackActionsOptions) {
if (targetView !== 'editor' || !targetStack || targetStack !== stackListState.selectedFile) {
return isComposeDirty() || isEnvDirty();
}
// Leaving Monaco for stack detail on the same stack.
if (editorState.editingCompose && parsed.editorTab == null) {
return isComposeDirty() || isEnvDirty();
}
if (
parsed.editorTab === 'env'
&& editorState.activeTab === 'env'
@@ -55,6 +55,8 @@ function makeOpts(over: Partial<UseUrlSyncOptions> = {}): UseUrlSyncOptions {
isFileLoading: false,
activeTab: 'compose',
setActiveTab: vi.fn(),
editingCompose: false,
setEditingCompose: vi.fn(),
selectedEnvFile: '',
envFiles: [],
loadFileForRoute: vi.fn().mockResolvedValue({ ok: true, envFiles: [] }),
@@ -494,4 +496,86 @@ describe('useUrlSync', () => {
expect(result.current.routeDetailError).toBeNull();
});
it('hydrates tabless stack URL as detail without opening Monaco', async () => {
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
const applyEditorRouteState = vi.fn();
const setEditingCompose = vi.fn();
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr');
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'editor',
files: ['radarr'],
selectedFile: null,
envFiles: [],
loadFileForRoute,
applyEditorRouteState,
setEditingCompose,
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
expect(applyEditorRouteState).not.toHaveBeenCalled();
expect(setEditingCompose).toHaveBeenCalledWith(false);
});
it('writes tabless stack URL when detail is open', () => {
const pushSpy = vi.spyOn(window.history, 'pushState');
const { rerender } = renderHook(
(props) => useUrlSync(props),
{ initialProps: makeOpts({ activeView: 'dashboard' }) },
);
act(() => {
rerender(makeOpts({
activeView: 'editor',
selectedFile: 'radarr',
editingCompose: false,
activeTab: 'compose',
}));
});
const pushed = pushSpy.mock.calls.map((call) => String(call[2] ?? ''));
expect(pushed.some((p) => p === '/nodes/local/stacks/radarr')).toBe(true);
expect(pushed.some((p) => p.includes('/compose'))).toBe(false);
pushSpy.mockRestore();
});
it('opens Monaco when hydrating /compose deep link', async () => {
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
const applyEditorRouteState = vi.fn();
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'editor',
files: ['radarr'],
selectedFile: null,
loadFileForRoute,
applyEditorRouteState,
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
expect(applyEditorRouteState).toHaveBeenCalledWith('compose');
});
});
@@ -26,7 +26,8 @@ interface HistoryState {
interface PendingRoute {
view: ActiveView;
stackName: string | null;
editorTab: EditorTab;
/** Null = stack detail (anatomy); set = Monaco tab surface. */
editorTab: EditorTab | null;
envFile: string | null;
securityTab: SecurityTab;
fleetTab: FleetTab;
@@ -58,6 +59,8 @@ export interface UseUrlSyncOptions {
isFileLoading: boolean;
activeTab: EditorTab;
setActiveTab: (tab: EditorTab) => void;
editingCompose: boolean;
setEditingCompose: (editing: boolean) => void;
selectedEnvFile: string;
envFiles: string[];
loadFileForRoute: (filename: string) => Promise<RouteStackLoadResult>;
@@ -98,10 +101,17 @@ interface PendingEditorRouteOpts {
async function applyPendingEditorRoute(
optsRef: MutableRefObject<UseUrlSyncOptions>,
pendingEnvRef: MutableRefObject<string | null>,
tab: EditorTab,
tab: EditorTab | null,
routeOpts?: PendingEditorRouteOpts,
): Promise<boolean> {
const live = optsRef.current;
// Tabless stack URL → detail (anatomy). Do not open Monaco.
if (tab == null) {
pendingEnvRef.current = null;
live.setEditingCompose(false);
live.setActiveTab('compose');
return true;
}
if (tab !== 'env') {
pendingEnvRef.current = null;
live.setActiveTab(tab);
@@ -122,6 +132,7 @@ async function applyPendingEditorRoute(
pendingEnvRef.current = null;
let effectiveTab: EditorTab = tab;
if (outcome.target == null && envFiles.length === 0) {
// Empty env inventory: stay on Monaco compose tab rather than detail.
effectiveTab = 'compose';
} else if (outcome.target && outcome.target !== live.selectedEnvFile) {
await live.changeEnvFile(outcome.target);
@@ -175,7 +186,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
nodeSlug: slug,
activeView: view,
stackName: o.selectedFile,
editorTab: o.activeTab,
editorTab: o.editingCompose ? o.activeTab : null,
envFile: envFileForRouteUrl(o.selectedEnvFile, o.envFiles, o.activeTab),
securityTab: o.securityTab,
fleetActiveTab: o.fleetActiveTab,
@@ -273,7 +284,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
// to avoid unmounting the editor (loadFileCore clears selectedFile on
// failure, which would hide the recovery chip during a deploy).
if (o.selectedFile === match) {
const tab = pendingTabRef.current ?? 'compose';
const tab = pendingTabRef.current;
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
envFiles: o.envFiles,
inventoryReady: !o.isFileLoading,
@@ -302,7 +313,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
return;
}
const tab = pendingTabRef.current ?? 'compose';
const tab = pendingTabRef.current;
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
envFiles: loadResult.envFiles,
inventoryReady: true,
@@ -384,7 +395,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
pendingRouteRef.current = {
view,
stackName: parsed.stackName,
editorTab: parsed.editorTab ?? 'compose',
editorTab: parsed.editorTab,
envFile: parsed.envFile,
securityTab: parsed.securityTab ?? 'overview',
fleetTab,
@@ -473,6 +484,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
options.activeView,
options.selectedFile,
options.activeTab,
options.editingCompose,
options.selectedEnvFile,
options.envFiles,
options.securityTab,