diff --git a/packages/hub-ui/src/client/components/views/ViewIframe.vue b/packages/hub-ui/src/client/components/views/ViewIframe.vue index 45521ae1..0cc91d18 100644 --- a/packages/hub-ui/src/client/components/views/ViewIframe.vue +++ b/packages/hub-ui/src/client/components/views/ViewIframe.vue @@ -4,7 +4,8 @@ import type { DocksContext } from '@devframes/hub/client' import type { RemoteAssetsErrorMessage } from 'devframe/types' import type { IframePanes } from 'iframe-pane' import type { CSSProperties } from 'vue' -import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, REMOTE_CONNECTION_KEY } from '@devframes/hub/constants' +import { stripRemoteConnectionFromUrl, watchFrameLocation } from '@devframes/hub/client' +import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '@devframes/hub/constants' import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue' import { sharedStateToRef } from '../../state/docks' import ViewAssetsError from './ViewAssetsError.vue' @@ -17,34 +18,6 @@ const props = defineProps<{ iframeStyle?: CSSProperties }>() -function stripRemoteConnectionParam(url: string): string { - // Remove the remote connection descriptor so the auth token isn't exposed - // in the address bar (user could accidentally copy it). - let result = url - - const hashIdx = result.indexOf('#') - if (hashIdx !== -1) { - const hash = result.slice(hashIdx + 1) - const filtered = hash - .split('&') - .filter(part => !part.startsWith(`${REMOTE_CONNECTION_KEY}=`)) - .join('&') - result = filtered ? `${result.slice(0, hashIdx)}#${filtered}` : result.slice(0, hashIdx) - } - - const qIdx = result.indexOf('?') - if (qIdx !== -1) { - const query = result.slice(qIdx + 1) - const filtered = query - .split('&') - .filter(part => !part.startsWith(`${REMOTE_CONNECTION_KEY}=`)) - .join('&') - result = filtered ? `${result.slice(0, qIdx)}?${filtered}` : result.slice(0, qIdx) - } - - return result -} - const settings = sharedStateToRef(props.context.docks.settings) const isEdgeMode = computed(() => props.context.panel.store.mode === 'edge') const showAddressBar = computed(() => settings.value.showIframeAddressBar ?? true) @@ -112,9 +85,12 @@ const isCrossOrigin = computed(() => { } }) -// Display URL - hides host if same as current page +// Display URL - hides host if same as current page. The remote connection +// descriptor is stripped so its auth token can't be read (or copied) out of the +// address bar; the route persisted for a reload keeps it, since the restored +// iframe still has to connect. const displayUrl = computed(() => { - const sanitized = stripRemoteConnectionParam(currentUrl.value) + const sanitized = stripRemoteConnectionFromUrl(currentUrl.value) if (isCrossOrigin.value) { return sanitized } @@ -128,19 +104,6 @@ const displayUrl = computed(() => { } }) -function updateCurrentUrl() { - try { - // Try to get the current URL from the iframe (may fail due to cross-origin) - const iframe = iframeElement.value - if (iframe?.contentWindow?.location?.href) { - currentUrl.value = iframe.contentWindow.location.href - } - } - catch { - // Cross-origin restriction, keep the last known URL - } -} - function onWindowMessage(event: MessageEvent) { const data = event.data as Partial | null if (typeof data !== 'object' || data === null || data.type !== DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE) @@ -233,6 +196,7 @@ function refresh() { } let onIframeLoad: (() => void) | undefined +let stopLocationWatch: (() => void) | undefined onMounted(() => { const existed = props.panes.has(paneKey.value) @@ -255,9 +219,21 @@ onMounted(() => { }) const iframe = pane.iframe - if (existed) - updateCurrentUrl() - else + // Follow the frame wherever it goes — a document load, but also an SPA + // router's `pushState`/`replaceState` and back/forward, none of which fire + // `load`. `currentUrl` is the single source the address bar renders and the + // session route persists, so tracking it here keeps both live. Reattaching + // to an already-live pane reports its current href immediately if it moved + // on since the last time this view watched it. + stopLocationWatch = watchFrameLocation({ + iframe, + initial: currentUrl.value, + onChange: (href) => { + currentUrl.value = href + }, + }) + + if (!existed) // A freshly created pane is loading its initial content — reflect it so the // placeholder covers the first paint, not just later navigations. isIframeLoading.value = true @@ -274,7 +250,6 @@ onMounted(() => { // Listen for iframe load events onIframeLoad = () => { isIframeLoading.value = false - updateCurrentUrl() } iframe.addEventListener('load', onIframeLoad) @@ -326,6 +301,10 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener('message', onWindowMessage) + // A shared frame outlives this view, so its page is left exactly as found — + // the incoming view starts its own watch. + stopLocationWatch?.() + stopLocationWatch = undefined const pane = props.panes.get(paneKey.value) if (pane && onIframeLoad) pane.iframe?.removeEventListener('load', onIframeLoad) diff --git a/packages/hub/src/client/__tests__/frame-location.test.ts b/packages/hub/src/client/__tests__/frame-location.test.ts new file mode 100644 index 00000000..c224465f --- /dev/null +++ b/packages/hub/src/client/__tests__/frame-location.test.ts @@ -0,0 +1,260 @@ +import type { FrameLocationTarget, FrameLocationWindow } from '../frame-location' +import { describe, expect, it, vi } from 'vitest' +import { watchFrameLocation } from '../frame-location' + +type Listener = () => void + +interface FakeFrame { + iframe: FrameLocationTarget + /** Emit a same-document navigation the way a router would. */ + pushState: (href: string) => void + replaceState: (href: string) => void + /** + * Navigate without going through the frame's current `pushState` — a router + * that captured the method before the watch attached, which no wrapper sees. + */ + pushStateBypassingWrapper: (href: string) => void + /** Emit the Navigation API's post-commit notification. */ + emitCurrentEntryChange: () => void + /** Emit a browser-driven same-document navigation. */ + emit: (type: 'popstate' | 'hashchange', href: string) => void + /** Swap in a new document (new window, new `history`) and fire `load`. */ + load: (href: string) => void + /** Whether the frame's `history` methods are the ones it started with. */ + isHistoryPristine: () => boolean + /** Whether every listener the watcher attached has been removed. */ + isDetached: () => boolean +} + +/** + * A frame standing in for a same-origin iframe. `navigation: true` gives it the + * Navigation API on top of `history`, the way a browser that ships it does. + */ +function fakeFrame(initialHref: string, options: { navigation?: boolean, crossOrigin?: boolean } = {}): FakeFrame { + const loadListeners = new Set() + let win: FrameLocationWindow + let href = initialHref + let winListeners: Map> + let navListeners: Set + let pristinePush: (...args: any[]) => void + let pristineReplace: (...args: any[]) => void + + function createWindow(): void { + const listeners = new Map>([['popstate', new Set()], ['hashchange', new Set()]]) + const nav = new Set() + const history = { + pushState: (...args: any[]) => { + href = String(args[2]) + }, + replaceState: (...args: any[]) => { + href = String(args[2]) + }, + } + pristinePush = history.pushState + pristineReplace = history.replaceState + winListeners = listeners + navListeners = nav + win = { + get location() { + if (options.crossOrigin) + throw new DOMException('cross-origin', 'SecurityError') + return { + get href() { + return href + }, + } + }, + history, + navigation: options.navigation + ? { + addEventListener: (_type, listener) => void nav.add(listener), + removeEventListener: (_type, listener) => void nav.delete(listener), + } + : undefined, + addEventListener: (type, listener) => void listeners.get(type)!.add(listener), + removeEventListener: (type, listener) => void listeners.get(type)!.delete(listener), + } + } + + createWindow() + + const iframe: FrameLocationTarget = { + get contentWindow() { + return win + }, + addEventListener: (_type, listener) => void loadListeners.add(listener), + removeEventListener: (_type, listener) => void loadListeners.delete(listener), + } + + function emitCurrentEntryChange(): void { + for (const listener of [...navListeners]) listener() + } + + return { + iframe, + emitCurrentEntryChange, + pushState: (next) => { + win.history!.pushState({}, '', next) + }, + replaceState: (next) => { + win.history!.replaceState({}, '', next) + }, + pushStateBypassingWrapper: (next) => { + pristinePush({}, '', next) + emitCurrentEntryChange() + }, + emit: (type, next) => { + href = next + for (const listener of [...winListeners.get(type)!]) listener() + }, + load: (next) => { + createWindow() + href = next + for (const listener of [...loadListeners]) listener() + }, + isHistoryPristine: () => + win.history!.pushState === pristinePush && win.history!.replaceState === pristineReplace, + isDetached: () => + loadListeners.size === 0 + && navListeners.size === 0 + && [...winListeners.values()].every(set => set.size === 0), + } +} + +describe('watchFrameLocation', () => { + it('reports pushState and replaceState by wrapping them, and restores them on dispose', () => { + const frame = fakeFrame('http://localhost/app/') + const onChange = vi.fn() + const dispose = watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + expect(onChange).not.toHaveBeenCalled() + expect(frame.isHistoryPristine()).toBe(false) + + frame.pushState('http://localhost/app/routes') + frame.replaceState('http://localhost/app/routes?tab=2') + expect(onChange.mock.calls.map(c => c[0])).toEqual([ + 'http://localhost/app/routes', + 'http://localhost/app/routes?tab=2', + ]) + + dispose() + expect(frame.isHistoryPristine()).toBe(true) + expect(frame.isDetached()).toBe(true) + + frame.pushState('http://localhost/app/after-dispose') + expect(onChange).toHaveBeenCalledTimes(2) + }) + + it('the wrapper still performs the navigation it wraps', () => { + const frame = fakeFrame('http://localhost/app/') + watchFrameLocation({ iframe: frame.iframe, onChange: () => {}, initial: 'http://localhost/app/' }) + frame.pushState('http://localhost/app/moved') + expect(frame.iframe.contentWindow!.location.href).toBe('http://localhost/app/moved') + }) + + it('reports pushState where the Navigation API exists too, without depending on it', () => { + // Whether `pushState` fires a Navigation API event is not something to bet a + // stale address bar on, so the wrapper stays in place either way. + const frame = fakeFrame('http://localhost/app/', { navigation: true }) + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + frame.pushState('http://localhost/app/routes') + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/routes') + }) + + it('catches a navigation the wrapper cannot see, via currententrychange', () => { + const frame = fakeFrame('http://localhost/app/', { navigation: true }) + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + frame.pushStateBypassingWrapper('http://localhost/app/router-owned') + expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/router-owned') + }) + + it('reports a navigation heard from two sources once', () => { + const frame = fakeFrame('http://localhost/app/', { navigation: true }) + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + // A browser where `pushState` does fire the Navigation API event: the + // wrapper and the listener both report, and the href dedupe absorbs it. + frame.pushState('http://localhost/app/routes') + frame.emitCurrentEntryChange() + expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/routes') + }) + + it('reports back/forward and hash routing', () => { + const frame = fakeFrame('http://localhost/app/') + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + frame.emit('popstate', 'http://localhost/app/back') + frame.emit('hashchange', 'http://localhost/app/back#section') + expect(onChange.mock.calls.map(c => c[0])).toEqual([ + 'http://localhost/app/back', + 'http://localhost/app/back#section', + ]) + }) + + it('re-subscribes after a document load, whose window and history are new', () => { + const frame = fakeFrame('http://localhost/app/') + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + frame.load('http://localhost/app/reloaded') + expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/reloaded') + + // The pre-load subscription died with the old window; only a fresh one on + // the new `history` object keeps soft navigations reported. + frame.pushState('http://localhost/app/reloaded/deep') + expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/reloaded/deep') + }) + + it('reports an already-navigated frame on attach, but never the blank placeholder', () => { + const booting = fakeFrame('about:blank') + const onBoot = vi.fn() + watchFrameLocation({ iframe: booting.iframe, onChange: onBoot, initial: 'http://localhost/app/' }) + expect(onBoot).not.toHaveBeenCalled() + + // A frame that soft-navigated while no view was watching it (a shared frame + // between dock switches) is corrected as soon as the next watch attaches. + const live = fakeFrame('http://localhost/app/elsewhere') + const onAttach = vi.fn() + watchFrameLocation({ iframe: live.iframe, onChange: onAttach, initial: 'http://localhost/app/' }) + expect(onAttach).toHaveBeenCalledExactlyOnceWith('http://localhost/app/elsewhere') + }) + + it('reports each distinct href once', () => { + const frame = fakeFrame('http://localhost/app/') + const onChange = vi.fn() + watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + frame.emit('popstate', 'http://localhost/app/same') + frame.emit('popstate', 'http://localhost/app/same') + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('observes nothing on a cross-origin frame, and does not throw', () => { + const frame = fakeFrame('http://elsewhere.test/app/', { crossOrigin: true }) + const onChange = vi.fn() + const dispose = watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' }) + + expect(onChange).not.toHaveBeenCalled() + expect(() => frame.load('http://elsewhere.test/app/other')).not.toThrow() + expect(onChange).not.toHaveBeenCalled() + expect(() => dispose()).not.toThrow() + }) + + it('tolerates a frame with no contentWindow', () => { + const onChange = vi.fn() + const iframe: FrameLocationTarget = { + contentWindow: null, + addEventListener: () => {}, + removeEventListener: () => {}, + } + expect(() => watchFrameLocation({ iframe, onChange })()).not.toThrow() + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/packages/hub/src/client/frame-location.ts b/packages/hub/src/client/frame-location.ts new file mode 100644 index 00000000..609e7fc4 --- /dev/null +++ b/packages/hub/src/client/frame-location.ts @@ -0,0 +1,165 @@ +/** + * Live location tracking for an iframe dock. + * + * An iframe dock's address bar — and the route persisted from it into + * `DockSessionStorage.selectedDockRoute` — has to follow wherever the embedded + * app actually goes. The `load` event only covers whole-document navigations, so + * an SPA router moving between routes with `history.pushState()` leaves both + * showing the URL the frame booted with. + * + * {@link watchFrameLocation} closes that gap for a same-origin frame, reporting + * `location.href` on every navigation it can observe: + * + * - `popstate` / `hashchange` — back/forward and hash routing; + * - `history.pushState`/`replaceState`, wrapped in place (and restored on + * dispose) because those two fire no event of their own; + * - the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)'s + * `currententrychange` where it exists, which reports post-commit and so also + * catches what a wrapper structurally cannot — a router holding a reference to + * `pushState` captured before this watch attached; + * - `load`, which also re-subscribes: a document navigation swaps the frame's + * `history`/`navigation` objects, so the previous subscription dies with them. + * + * These sources overlap deliberately rather than being chosen between, since a + * report is deduped against the last href — the cost of hearing the same + * navigation twice is nothing, and the cost of missing one is a stale route. + * + * A cross-origin frame reports nothing — its location is unreadable by design, + * so the caller keeps the last URL it knew. + */ + +/** The minimal `history` surface the watcher wraps. Injectable for tests. */ +export interface FrameLocationHistory { + pushState: (...args: any[]) => void + replaceState: (...args: any[]) => void +} + +/** The minimal frame-window surface the watcher reads and subscribes to. */ +export interface FrameLocationWindow { + readonly location: { readonly href: string } + readonly history?: FrameLocationHistory + /** Present only where the Navigation API ships. */ + readonly navigation?: { + addEventListener: (type: 'currententrychange', listener: () => void) => void + removeEventListener: (type: 'currententrychange', listener: () => void) => void + } + addEventListener: (type: 'popstate' | 'hashchange', listener: () => void) => void + removeEventListener: (type: 'popstate' | 'hashchange', listener: () => void) => void +} + +/** The minimal iframe surface the watcher needs. Injectable for tests. */ +export interface FrameLocationTarget { + readonly contentWindow: FrameLocationWindow | null + addEventListener: (type: 'load', listener: () => void) => void + removeEventListener: (type: 'load', listener: () => void) => void +} + +export interface WatchFrameLocationOptions { + /** The live iframe whose location is tracked. */ + iframe: FrameLocationTarget + /** Called with the frame's `location.href` on each observed change. */ + onChange: (href: string) => void + /** + * The href the caller already shows, so an initial report is only made when + * the frame is somewhere else (a frame re-attached mid-session, say). + */ + initial?: string +} + +/** + * Track a frame's location until the returned disposer is called. Disposing + * detaches every listener and restores any wrapped `history` method, leaving the + * embedded page as it was found. + */ +export function watchFrameLocation(options: WatchFrameLocationOptions): () => void { + const { iframe, onChange } = options + let lastReported = options.initial + let detach: (() => void) | undefined + let disposed = false + + /** + * The frame's window, or `null` when its location can't be read. Touching + * `location.href` is the cross-origin probe — it throws for a foreign + * document, where there is nothing to observe. + */ + function readableWindow(): FrameLocationWindow | null { + try { + const win = iframe.contentWindow + return win && typeof win.location.href === 'string' ? win : null + } + catch { + return null + } + } + + function report(): void { + const href = readableWindow()?.location.href + // `about:blank` is the placeholder document a fresh iframe holds until its + // `src` commits — reporting it would overwrite the real route with a blank. + if (!href || href === 'about:blank' || href === lastReported) + return + lastReported = href + onChange(href) + } + + function subscribe(): void { + const win = readableWindow() + if (!win) + return + const listeners: (() => void)[] = [] + + win.addEventListener('popstate', report) + win.addEventListener('hashchange', report) + listeners.push(() => { + win.removeEventListener('popstate', report) + win.removeEventListener('hashchange', report) + }) + + const { navigation, history } = win + if (navigation) { + navigation.addEventListener('currententrychange', report) + listeners.push(() => navigation.removeEventListener('currententrychange', report)) + } + if (history) { + // Wrap rather than replace: the original still does the navigating, and + // goes back onto the object untouched when this watcher is disposed. + const originals = { pushState: history.pushState, replaceState: history.replaceState } + for (const method of ['pushState', 'replaceState'] as const) { + history[method] = function (this: unknown, ...args: any[]): void { + originals[method].apply(this, args) + report() + } + } + listeners.push(() => { + history.pushState = originals.pushState + history.replaceState = originals.replaceState + }) + } + + detach = () => { + for (const off of listeners) off() + } + } + + function onLoad(): void { + if (disposed) + return + detach?.() + detach = undefined + subscribe() + report() + } + + iframe.addEventListener('load', onLoad) + subscribe() + report() + + return () => { + if (disposed) + return + disposed = true + iframe.removeEventListener('load', onLoad) + detach?.() + detach = undefined + } +} diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index fb49bc7c..97bdfebd 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -2,6 +2,7 @@ export * from './client-script' export * from './context' export * from './dock-resources' export * from './docks' +export * from './frame-location' export * from './frame-nav' export * from './host' export * from './messages' diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 0d10114a..14916974 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -118,6 +118,27 @@ export interface DocksPanelContext { readonly isVertical: boolean; consumeBootRoute?: (_: string) => string | null; } +export interface FrameLocationHistory { + pushState: (..._: any[]) => void; + replaceState: (..._: any[]) => void; +} +export interface FrameLocationTarget { + readonly contentWindow: FrameLocationWindow | null; + addEventListener: (_: 'load', _: () => void) => void; + removeEventListener: (_: 'load', _: () => void) => void; +} +export interface FrameLocationWindow { + readonly location: { + readonly href: string; + }; + readonly history?: FrameLocationHistory; + readonly navigation?: { + addEventListener: (_: 'currententrychange', _: () => void) => void; + removeEventListener: (_: 'currententrychange', _: () => void) => void; + }; + addEventListener: (_: 'popstate' | 'hashchange', _: () => void) => void; + removeEventListener: (_: 'popstate' | 'hashchange', _: () => void) => void; +} export interface FrameNavClient { readonly ready: boolean; readonly currentTabId: string | null; @@ -157,6 +178,11 @@ export interface FrameTab { export interface MessagesClientOptions { defaults?: Partial; } +export interface WatchFrameLocationOptions { + iframe: FrameLocationTarget; + onChange: (_: string) => void; + initial?: string; +} export interface WhenClauseContext { readonly context: WhenContext; } @@ -217,6 +243,7 @@ export declare function resolveDockIcon(_: DevframeDockEntryIcon, _: DevframeCon export declare function resolveDockUrl(_: string, _: DevframeConnection): string; export declare function setDevframeClientContext(_: DevframeClientContext | undefined): void; export declare function stripRemoteConnectionFromUrl(_: string): string; +export declare function watchFrameLocation(_: WatchFrameLocationOptions): () => void; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js index 3b549ae6..6115ae75 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.js @@ -13,6 +13,7 @@ export function parseRemoteConnection(_) {} export function resolveDockIcon(_, _) {} export function resolveDockUrl(_, _) {} export function setDevframeClientContext(_) {} +export function watchFrameLocation(_) {} // #endregion // #region Variables