fix(routing): basename env URLs and defer writes during node hydration (#1598)

* fix(routing): basename env URLs and defer writes during node hydration

Encode only env file basenames in ?env= and omit the default file.

Resolve legacy absolute-path bookmarks on load.

Block history writes until the active node matches a cold-loaded remote deep link.

* fix(routing): restore env deep links after stack load hydrates file list

Defer env selection until envFiles is populated after loadFileForRoute.

Apply default env when the URL omits ?env= (Back/popstate).

Document env URL rules and legacy basename resolution in deep-links.mdx.
This commit is contained in:
Anso
2026-07-08 21:02:13 -04:00
committed by GitHub
parent ad5e3f38c8
commit 1de9f5e4b2
8 changed files with 276 additions and 21 deletions
@@ -9,6 +9,7 @@ import type { OverlayState } from './useOverlayState';
import type { Node } from '@/context/NodeContext';
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import { parsePath } from '@/lib/router/senchoRoute';
import { resolveEnvFilePath } from '@/lib/router/envRoute';
import type { EditorTab } from '@/lib/router/routeTypes';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
@@ -1249,9 +1250,11 @@ export function useStackActions(options: UseStackActionsOptions) {
parsed.editorTab === 'env'
&& editorState.activeTab === 'env'
&& parsed.envFile
&& parsed.envFile !== editorState.selectedEnvFile
) {
return isEnvDirty();
const resolved = resolveEnvFilePath(parsed.envFile, editorState.envFiles);
if (resolved && resolved !== editorState.selectedEnvFile) {
return isEnvDirty();
}
}
return false;
};
@@ -97,6 +97,79 @@ describe('useUrlSync', () => {
expect(window.location.pathname).toBe('/nodes/local/security');
});
it('does not write local node URL while hydrating a remote node deep link', () => {
const remote = makeNode({ id: 2, name: 'nas', type: 'remote', is_default: false });
const local = makeNode();
const setActiveNode = vi.fn();
const pushSpy = vi.spyOn(window.history, 'pushState');
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/nas-2/fleet/snapshots');
act(() => {
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
nodes: [local, remote],
activeNode: local,
activeView: 'fleet',
fleetActiveTab: 'snapshots',
setActiveNode,
}),
},
);
});
const badPush = pushSpy.mock.calls.find((call) => String(call[2]).includes('/nodes/local/'));
expect(badPush).toBeUndefined();
expect(setActiveNode).toHaveBeenCalledWith(remote);
pushSpy.mockRestore();
});
it('restores non-default env selection after stack load populates file list', async () => {
const prodPath = '/compose/radarr/.env.prod';
const fileList = ['/compose/radarr/.env', prodPath];
const loadFileForRoute = vi.fn().mockResolvedValue(true);
const changeEnvFile = vi.fn().mockResolvedValue(undefined);
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env?env=.env.prod');
const { rerender } = renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'editor',
files: ['radarr'],
selectedFile: null,
envFiles: [],
loadFileForRoute,
changeEnvFile,
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
rerender(makeOpts({
activeView: 'editor',
files: ['radarr'],
selectedFile: 'radarr',
envFiles: fileList,
loadFileForRoute,
changeEnvFile,
}));
await act(async () => {
await Promise.resolve();
});
expect(changeEnvFile).toHaveBeenCalledWith(prodPath);
});
it('pushState increments senchoIdx on user navigation', () => {
const pushSpy = vi.spyOn(window.history, 'pushState');
@@ -1,9 +1,10 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useRef, useCallback, useState, type MutableRefObject } from 'react';
import type { Node } from '@/context/NodeContext';
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView, EditorTab, MobileRouteSurface } from '@/lib/router/routeTypes';
import { buildPath, parsePath } from '@/lib/router/senchoRoute';
import { envFileForRouteUrl, resolveEnvRouteTarget } from '@/lib/router/envRoute';
import { nodeIdToSlug, slugToNodeId } from '@/lib/nodeSlug';
import {
authzReady,
@@ -85,6 +86,29 @@ function readIdx(state: unknown): number | null {
return null;
}
async function applyPendingEditorRoute(
optsRef: MutableRefObject<UseUrlSyncOptions>,
pendingEnvRef: MutableRefObject<string | null>,
tab: EditorTab,
): Promise<boolean> {
const live = optsRef.current;
if (tab !== 'env') {
pendingEnvRef.current = null;
live.setActiveTab(tab);
live.applyEditorRouteState(tab);
return true;
}
const outcome = resolveEnvRouteTarget(pendingEnvRef.current, live.envFiles);
if (!outcome.ready) return false;
pendingEnvRef.current = null;
if (outcome.target && outcome.target !== live.selectedEnvFile) {
await live.changeEnvFile(outcome.target);
}
live.setActiveTab(tab);
live.applyEditorRouteState(tab);
return true;
}
export function useUrlSync(options: UseUrlSyncOptions) {
const optsRef = useRef(options);
optsRef.current = options;
@@ -102,6 +126,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
const initialHydratedRef = useRef(false);
const routeReplaceRef = useRef(false);
const appliedViewRef = useRef<ActiveView | null>(null);
const pendingNodeIdRef = useRef<number | null>(null);
const resolvingRef = useRef(false);
const writeHistory = useCallback((path: string, intent: RouteIntent) => {
@@ -129,7 +154,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
activeView: view,
stackName: o.selectedFile,
editorTab: o.activeTab,
envFile: o.selectedEnvFile || null,
envFile: envFileForRouteUrl(o.selectedEnvFile, o.envFiles, o.activeTab),
securityTab: o.securityTab,
fleetActiveTab: o.fleetActiveTab,
settingsSection: o.isMobile ? o.mobileSettingsSection : o.settingsSection,
@@ -179,7 +204,9 @@ export function useUrlSync(options: UseUrlSyncOptions) {
setUrlHydratingStack(null);
setRouteDetailError(null);
pendingRouteRef.current = null;
if (o.activeView === view) {
const nodeReady = pendingNodeIdRef.current == null || o.activeNode?.id === pendingNodeIdRef.current;
if (o.activeView === view && nodeReady) {
pendingNodeIdRef.current = null;
appliedViewRef.current = null;
phaseRef.current = 'settled';
} else {
@@ -222,19 +249,15 @@ 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 env = pendingEnvRef.current;
const tab = pendingTabRef.current ?? 'compose';
if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) {
await o.changeEnvFile(env);
}
o.setActiveTab(tab);
o.applyEditorRouteState(tab);
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab);
if (!applied) return;
pendingStackRef.current = null;
pendingEnvRef.current = null;
pendingTabRef.current = null;
pendingRouteRef.current = null;
setUrlHydratingStack(null);
setRouteDetailError(null);
pendingNodeIdRef.current = null;
phaseRef.current = 'settled';
return;
}
@@ -252,20 +275,19 @@ export function useUrlSync(options: UseUrlSyncOptions) {
return;
}
const env = pendingEnvRef.current;
const tab = pendingTabRef.current ?? 'compose';
if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) {
await o.changeEnvFile(env);
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab);
if (!applied) {
resolvingRef.current = false;
return;
}
o.setActiveTab(tab);
o.applyEditorRouteState(tab);
pendingStackRef.current = null;
pendingEnvRef.current = null;
pendingTabRef.current = null;
pendingRouteRef.current = null;
setUrlHydratingStack(null);
setRouteDetailError(null);
pendingNodeIdRef.current = null;
phaseRef.current = 'settled';
resolvingRef.current = false;
}, []);
@@ -343,8 +365,11 @@ export function useUrlSync(options: UseUrlSyncOptions) {
phaseRef.current = 'applying';
if (o.activeNode?.id !== node.id) {
pendingNodeIdRef.current = node.id;
routeReplaceRef.current = true;
o.setActiveNode(node);
} else {
pendingNodeIdRef.current = null;
void applyPendingRoute();
}
}, [applyPendingRoute, writeHistory]);
@@ -390,17 +415,20 @@ export function useUrlSync(options: UseUrlSyncOptions) {
useEffect(() => {
if (phaseRef.current !== 'applying') return;
if (pendingStackRef.current) return;
if (pendingNodeIdRef.current != null && options.activeNode?.id !== pendingNodeIdRef.current) return;
const applied = appliedViewRef.current;
if (applied == null) return;
if (options.activeView !== applied) return;
appliedViewRef.current = null;
pendingNodeIdRef.current = null;
phaseRef.current = 'settled';
}, [options.activeView]);
}, [options.activeView, options.activeNode?.id]);
useEffect(() => {
if (phaseRef.current !== 'settled') return;
if (options.isFileLoading) return;
if (pendingStackRef.current) return;
if (pendingNodeIdRef.current != null && options.activeNode?.id !== pendingNodeIdRef.current) return;
if (options.activeView === 'editor' && !options.selectedFile) return;
const target = buildCurrentPath();
@@ -416,6 +444,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
options.selectedFile,
options.activeTab,
options.selectedEnvFile,
options.envFiles,
options.securityTab,
options.fleetActiveTab,
options.settingsSection,