fix: keep browser tab session navigation stable
Keep the browser pane loaded URL separate from the in-frame current URL so SPA navigation updates the address bar and history without remounting the iframe or resetting the Electron webview src. Preserve parsed ?session= route params during initial URL normalization and pass a directory hint when applying deep links, preventing embedded OpenChamber sessions from collapsing back to / while bootstrap catches up.
This commit is contained in:
@@ -1306,6 +1306,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
const startUrl = normalized !== 'about:blank' ? normalized : '';
|
const startUrl = normalized !== 'about:blank' ? normalized : '';
|
||||||
const [urlInput, setUrlInput] = React.useState(startUrl);
|
const [urlInput, setUrlInput] = React.useState(startUrl);
|
||||||
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
|
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
|
||||||
|
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
|
||||||
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
|
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
|
||||||
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
|
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
|
||||||
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
|
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
|
||||||
@@ -1324,12 +1325,17 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
setContextPanelTabTargetPath(directory, tabID, url);
|
setContextPanelTabTargetPath(directory, tabID, url);
|
||||||
}, [directory, tabID, setContextPanelTabTargetPath]);
|
}, [directory, tabID, setContextPanelTabTargetPath]);
|
||||||
|
|
||||||
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean }) => {
|
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean; inFrame?: boolean }) => {
|
||||||
const normalizedUrl = normalizeBrowserUrl(url);
|
const normalizedUrl = normalizeBrowserUrl(url);
|
||||||
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
|
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
|
||||||
setCurrentUrl(nextUrl);
|
setCurrentUrl(nextUrl);
|
||||||
setUrlInput(nextUrl);
|
setUrlInput(nextUrl);
|
||||||
setIsLoading(Boolean(nextUrl));
|
if (!options?.inFrame) {
|
||||||
|
setLoadedUrl(nextUrl);
|
||||||
|
setIsLoading(Boolean(nextUrl));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
persistUrl(nextUrl);
|
persistUrl(nextUrl);
|
||||||
|
|
||||||
setHistory((current) => {
|
setHistory((current) => {
|
||||||
@@ -1360,6 +1366,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
if (!nextUrl) return;
|
if (!nextUrl) return;
|
||||||
setHistoryIndex(nextIndex);
|
setHistoryIndex(nextIndex);
|
||||||
setCurrentUrl(nextUrl);
|
setCurrentUrl(nextUrl);
|
||||||
|
setLoadedUrl(nextUrl);
|
||||||
setUrlInput(nextUrl);
|
setUrlInput(nextUrl);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
persistUrl(nextUrl);
|
persistUrl(nextUrl);
|
||||||
@@ -1376,12 +1383,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
}, [currentUrl]);
|
}, [currentUrl]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentUrl) {
|
if (!loadedUrl) {
|
||||||
setProxyState({ status: 'idle' });
|
setProxyState({ status: 'idle' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
|
const proxyTargetKey = getBrowserProxyTargetKey(loadedUrl);
|
||||||
const cached = getCachedProxyTarget(proxyTargetKey);
|
const cached = getCachedProxyTarget(proxyTargetKey);
|
||||||
if (cached?.previewToken) {
|
if (cached?.previewToken) {
|
||||||
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
|
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
|
||||||
@@ -1401,7 +1408,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ url: currentUrl, allowExternal: true }),
|
body: JSON.stringify({ url: loadedUrl, allowExternal: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1441,9 +1448,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentUrl, t]);
|
}, [loadedUrl, t]);
|
||||||
|
|
||||||
const proxyUrlAuthKey = currentUrl && proxyState.status === 'ready'
|
const proxyUrlAuthKey = loadedUrl && proxyState.status === 'ready'
|
||||||
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
|
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -1468,9 +1475,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
|
|
||||||
const proxySrc = React.useMemo(() => {
|
const proxySrc = React.useMemo(() => {
|
||||||
if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
|
if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
|
||||||
if (!currentUrl || proxyState.status !== 'ready') return '';
|
if (!loadedUrl || proxyState.status !== 'ready') return '';
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(currentUrl);
|
const parsed = new URL(loadedUrl);
|
||||||
const path = parsed.pathname || '/';
|
const path = parsed.pathname || '/';
|
||||||
const searchParams = new URLSearchParams(parsed.search);
|
const searchParams = new URLSearchParams(parsed.search);
|
||||||
searchParams.delete('oc_url_token');
|
searchParams.delete('oc_url_token');
|
||||||
@@ -1482,12 +1489,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [currentUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
|
}, [loadedUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
|
||||||
|
|
||||||
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
|
const iframeSrc = proxySrc || (proxyState.status === 'error' ? loadedUrl : '');
|
||||||
|
|
||||||
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
|
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||||
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
|
||||||
try {
|
try {
|
||||||
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||||
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
||||||
@@ -1498,18 +1505,18 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
|
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
|
||||||
const upstreamOrigin = new URL(currentUrl).origin;
|
const upstreamOrigin = new URL(loadedUrl).origin;
|
||||||
return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
|
return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [currentUrl, proxyState]);
|
}, [loadedUrl, proxyState]);
|
||||||
|
|
||||||
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
|
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||||
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
|
||||||
try {
|
try {
|
||||||
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||||
const upstreamOrigin = new URL(currentUrl).origin;
|
const upstreamOrigin = new URL(loadedUrl).origin;
|
||||||
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
|
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -1525,7 +1532,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}, [currentUrl, proxyState]);
|
}, [loadedUrl, proxyState]);
|
||||||
|
|
||||||
const postInspectMode = React.useCallback((enabled: boolean) => {
|
const postInspectMode = React.useCallback((enabled: boolean) => {
|
||||||
const frameWindow = iframeRef.current?.contentWindow;
|
const frameWindow = iframeRef.current?.contentWindow;
|
||||||
@@ -1614,7 +1621,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
const frameUrl = typeof data.url === 'string' ? data.url : '';
|
const frameUrl = typeof data.url === 'string' ? data.url : '';
|
||||||
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
|
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
|
||||||
if (nextUrl && nextUrl !== currentUrl) {
|
if (nextUrl && nextUrl !== currentUrl) {
|
||||||
applyUrl(nextUrl);
|
applyUrl(nextUrl, { inFrame: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1685,8 +1692,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
|
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
|
||||||
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
|
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
|
||||||
if (upstreamUrl) {
|
if (upstreamUrl) {
|
||||||
setIsLoading(true);
|
applyUrl(upstreamUrl, { inFrame: true });
|
||||||
applyUrl(upstreamUrl);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1739,7 +1745,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
|||||||
{iframeSrc ? (
|
{iframeSrc ? (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<iframe
|
<iframe
|
||||||
key={`${iframeSrc}:${reloadNonce}`}
|
key={`${tabID}:${reloadNonce}`}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={iframeSrc}
|
src={iframeSrc}
|
||||||
title={t('contextPanel.browser.empty')}
|
title={t('contextPanel.browser.empty')}
|
||||||
@@ -1788,6 +1794,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
|||||||
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
|
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
|
||||||
const normalized = normalizeBrowserUrl(initialUrl);
|
const normalized = normalizeBrowserUrl(initialUrl);
|
||||||
const startUrl = normalized !== 'about:blank' ? normalized : '';
|
const startUrl = normalized !== 'about:blank' ? normalized : '';
|
||||||
|
const initialWebviewSrcRef = React.useRef(normalizeBrowserUrl(initialUrl));
|
||||||
const [urlInput, setUrlInput] = React.useState(startUrl);
|
const [urlInput, setUrlInput] = React.useState(startUrl);
|
||||||
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
|
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
|
||||||
const [isInspecting, setIsInspecting] = React.useState(false);
|
const [isInspecting, setIsInspecting] = React.useState(false);
|
||||||
@@ -2027,7 +2034,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
|||||||
<div className="relative min-h-0 flex-1 bg-background">
|
<div className="relative min-h-0 flex-1 bg-background">
|
||||||
<webview
|
<webview
|
||||||
ref={webviewRef}
|
ref={webviewRef}
|
||||||
src={normalizeBrowserUrl(initialUrl)}
|
src={initialWebviewSrcRef.current}
|
||||||
partition="persist:openchamber-browser"
|
partition="persist:openchamber-browser"
|
||||||
allowpopups
|
allowpopups
|
||||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export function useRouter(): void {
|
|||||||
if (route.sessionId) {
|
if (route.sessionId) {
|
||||||
const currentSessionId = useSessionUIStore.getState().currentSessionId;
|
const currentSessionId = useSessionUIStore.getState().currentSessionId;
|
||||||
if (route.sessionId !== currentSessionId) {
|
if (route.sessionId !== currentSessionId) {
|
||||||
await setCurrentSession(route.sessionId);
|
const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId);
|
||||||
|
setCurrentSession(route.sessionId, directoryHint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,14 +144,23 @@ export function useRouter(): void {
|
|||||||
const initializeRoute = async () => {
|
const initializeRoute = async () => {
|
||||||
await applyRoute(route);
|
await applyRoute(route);
|
||||||
|
|
||||||
// After applying, update URL to normalized form (use replaceState)
|
// After applying, update URL to normalized form (use replaceState).
|
||||||
|
// Use the parsed route values instead of an immediate store snapshot so
|
||||||
|
// deep links do not briefly normalize `?session=...` back to `/` while
|
||||||
|
// the session's directory/message bootstrap is still catching up.
|
||||||
if (!isVSCode) {
|
if (!isVSCode) {
|
||||||
syncURLFromState({ replace: true });
|
updateBrowserURL({
|
||||||
|
...getCurrentAppState(),
|
||||||
|
sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId,
|
||||||
|
tab: route.tab ?? useUIStore.getState().activeMainTab,
|
||||||
|
settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage,
|
||||||
|
diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile,
|
||||||
|
}, { replace: true, force: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void initializeRoute();
|
void initializeRoute();
|
||||||
}, [applyRoute, isVSCode, syncURLFromState]);
|
}, [applyRoute, getCurrentAppState, isVSCode]);
|
||||||
|
|
||||||
// Subscribe to session changes
|
// Subscribe to session changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user