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
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import {
envFileBasename,
envFileForRouteUrl,
normalizeEnvFileQuery,
resolveEnvFilePath,
resolveEnvRouteTarget,
} from './envRoute';
const envFiles = [
'/home/user/compose/radarr/.env',
'/home/user/compose/radarr/.env.prod',
];
describe('envRoute', () => {
it('extracts basename from absolute paths', () => {
expect(envFileBasename('/home/user/compose/radarr/.env.prod')).toBe('.env.prod');
expect(envFileBasename('C:\\compose\\stack\\.env')).toBe('.env');
});
it('resolves basename and legacy absolute URL tokens to full paths', () => {
expect(resolveEnvFilePath('.env.prod', envFiles)).toBe('/home/user/compose/radarr/.env.prod');
expect(resolveEnvFilePath('/home/user/compose/radarr/.env.prod', envFiles)).toBe(
'/home/user/compose/radarr/.env.prod',
);
expect(resolveEnvFilePath('.env.missing', envFiles)).toBeNull();
});
it('omits default env file from route URLs', () => {
expect(envFileForRouteUrl('/home/user/compose/radarr/.env', envFiles, 'env')).toBeNull();
expect(envFileForRouteUrl('/home/user/compose/radarr/.env.prod', envFiles, 'env')).toBe('.env.prod');
expect(envFileForRouteUrl('/home/user/compose/radarr/.env.prod', envFiles, 'compose')).toBeNull();
});
it('normalizes legacy absolute env query values to basenames', () => {
expect(normalizeEnvFileQuery('/home/user/compose/radarr/.env.prod')).toBe('.env.prod');
expect(normalizeEnvFileQuery('.env.prod')).toBe('.env.prod');
expect(normalizeEnvFileQuery('')).toBeNull();
});
it('resolveEnvRouteTarget waits for envFiles and falls back to default', () => {
expect(resolveEnvRouteTarget('.env.prod', [])).toEqual({ ready: false });
expect(resolveEnvRouteTarget('.env.prod', envFiles)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env.prod',
});
expect(resolveEnvRouteTarget(null, envFiles)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env',
});
expect(resolveEnvRouteTarget('.env.missing', envFiles)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env',
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { EditorTab } from './routeTypes';
/** Leaf name of an env file path (handles Windows separators). */
export function envFileBasename(file: string): string {
const normalized = file.replace(/\\/g, '/');
const leaf = normalized.slice(normalized.lastIndexOf('/') + 1);
return leaf || file;
}
/** Map a URL env token (basename or legacy absolute path) to a full env file path. */
export function resolveEnvFilePath(requested: string | null, envFiles: string[]): string | null {
if (!requested || envFiles.length === 0) return null;
if (envFiles.includes(requested)) return requested;
const want = envFileBasename(requested);
if (!want) return null;
return envFiles.find((f) => envFileBasename(f) === want) ?? null;
}
/** Env query value for the URL: basename only, omitted when the default file is selected. */
export function envFileForRouteUrl(
selectedEnvFile: string,
envFiles: string[],
activeTab: EditorTab,
): string | null {
if (activeTab !== 'env' || !selectedEnvFile) return null;
const first = envFiles[0];
if (first && selectedEnvFile === first) return null;
const basename = envFileBasename(selectedEnvFile);
if (!basename || basename.includes('/')) return null;
return basename;
}
/** Normalize a parsed env query to a basename (backward compat for bookmarked absolute paths). */
export function normalizeEnvFileQuery(raw: string | null): string | null {
if (!raw) return null;
const basename = envFileBasename(raw);
if (!basename || basename.length > 256) return null;
return basename;
}
export type EnvRouteTarget =
| { ready: false }
| { ready: true; target: string | null };
/** Pick the env file to open from a route token once envFiles are loaded. */
export function resolveEnvRouteTarget(requested: string | null, envFiles: string[]): EnvRouteTarget {
if (envFiles.length === 0) return { ready: false };
const defaultFile = envFiles[0];
if (!requested) return { ready: true, target: defaultFile };
const resolved = resolveEnvFilePath(requested, envFiles);
return { ready: true, target: resolved ?? defaultFile };
}
@@ -96,6 +96,25 @@ describe('senchoRoute', () => {
expect(path).toBe('/nodes/local/settings/appearance');
});
it('parses legacy absolute env query as basename', () => {
const parsed = parsePath(
'/nodes/local/stacks/radarr/env',
'?env=%2Fhome%2Fuser%2Fcompose%2Fradarr%2F.env.prod',
);
expect(parsed.envFile).toBe('.env.prod');
});
it('rejects absolute paths in buildPath env query', () => {
const path = buildPath({
...base,
activeView: 'editor',
stackName: 'radarr',
editorTab: 'env',
envFile: '/home/user/compose/radarr/.env.prod',
});
expect(path).toBe('/nodes/local/stacks/radarr/env');
});
it('parses stack list path as mobile list surface', () => {
const parsed = parsePath('/nodes/local/stacks', '');
expect(parsed.isStackList).toBe(true);
+4 -2
View File
@@ -1,6 +1,7 @@
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView, EditorTab, ParsedRoute, RouteState } from './routeTypes';
import { normalizeEnvFileQuery } from './envRoute';
const VIEW_SEGMENTS = {
dashboard: 'dashboard',
@@ -85,7 +86,8 @@ export function parsePath(pathname: string, search: string): ParsedRoute {
}
const filterNodeId = parseBoundedPositiveInt(params.get('node'));
const envFile = safeDecodeQueryValue(params.get('env') ?? '');
const envRaw = safeDecodeQueryValue(params.get('env') ?? '');
const envFile = normalizeEnvFileQuery(envRaw);
if (segment === 'stacks') {
if (parts.length === 3) {
@@ -138,7 +140,7 @@ export function buildPath(state: RouteState): string {
if (state.activeView === 'editor' && state.stackName) {
const tab = state.editorTab || 'compose';
url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`;
if (tab === 'env' && state.envFile) {
if (tab === 'env' && state.envFile && !state.envFile.includes('/')) {
url.searchParams.set('env', state.envFile);
}
if (state.filterNodeId != null) {