Skip to content

Commit c2f7c2f

Browse files
antfuclaudeCopilotantfubot
authored
feat(hub): track iframe soft navigations so the address bar and session route stay live (#252)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent c408e17 commit c2f7c2f

6 files changed

Lines changed: 481 additions & 48 deletions

File tree

packages/hub-ui/src/client/components/views/ViewIframe.vue

Lines changed: 27 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { DocksContext } from '@devframes/hub/client'
44
import type { RemoteAssetsErrorMessage } from 'devframe/types'
55
import type { IframePanes } from 'iframe-pane'
66
import type { CSSProperties } from 'vue'
7-
import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, REMOTE_CONNECTION_KEY } from '@devframes/hub/constants'
7+
import { stripRemoteConnectionFromUrl, watchFrameLocation } from '@devframes/hub/client'
8+
import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '@devframes/hub/constants'
89
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue'
910
import { sharedStateToRef } from '../../state/docks'
1011
import ViewAssetsError from './ViewAssetsError.vue'
@@ -17,34 +18,6 @@ const props = defineProps<{
1718
iframeStyle?: CSSProperties
1819
}>()
1920
20-
function stripRemoteConnectionParam(url: string): string {
21-
// Remove the remote connection descriptor so the auth token isn't exposed
22-
// in the address bar (user could accidentally copy it).
23-
let result = url
24-
25-
const hashIdx = result.indexOf('#')
26-
if (hashIdx !== -1) {
27-
const hash = result.slice(hashIdx + 1)
28-
const filtered = hash
29-
.split('&')
30-
.filter(part => !part.startsWith(`${REMOTE_CONNECTION_KEY}=`))
31-
.join('&')
32-
result = filtered ? `${result.slice(0, hashIdx)}#${filtered}` : result.slice(0, hashIdx)
33-
}
34-
35-
const qIdx = result.indexOf('?')
36-
if (qIdx !== -1) {
37-
const query = result.slice(qIdx + 1)
38-
const filtered = query
39-
.split('&')
40-
.filter(part => !part.startsWith(`${REMOTE_CONNECTION_KEY}=`))
41-
.join('&')
42-
result = filtered ? `${result.slice(0, qIdx)}?${filtered}` : result.slice(0, qIdx)
43-
}
44-
45-
return result
46-
}
47-
4821
const settings = sharedStateToRef(props.context.docks.settings)
4922
const isEdgeMode = computed(() => props.context.panel.store.mode === 'edge')
5023
const showAddressBar = computed(() => settings.value.showIframeAddressBar ?? true)
@@ -112,9 +85,12 @@ const isCrossOrigin = computed(() => {
11285
}
11386
})
11487
115-
// Display URL - hides host if same as current page
88+
// Display URL - hides host if same as current page. The remote connection
89+
// descriptor is stripped so its auth token can't be read (or copied) out of the
90+
// address bar; the route persisted for a reload keeps it, since the restored
91+
// iframe still has to connect.
11692
const displayUrl = computed(() => {
117-
const sanitized = stripRemoteConnectionParam(currentUrl.value)
93+
const sanitized = stripRemoteConnectionFromUrl(currentUrl.value)
11894
if (isCrossOrigin.value) {
11995
return sanitized
12096
}
@@ -128,19 +104,6 @@ const displayUrl = computed(() => {
128104
}
129105
})
130106
131-
function updateCurrentUrl() {
132-
try {
133-
// Try to get the current URL from the iframe (may fail due to cross-origin)
134-
const iframe = iframeElement.value
135-
if (iframe?.contentWindow?.location?.href) {
136-
currentUrl.value = iframe.contentWindow.location.href
137-
}
138-
}
139-
catch {
140-
// Cross-origin restriction, keep the last known URL
141-
}
142-
}
143-
144107
function onWindowMessage(event: MessageEvent) {
145108
const data = event.data as Partial<RemoteAssetsErrorMessage> | null
146109
if (typeof data !== 'object' || data === null || data.type !== DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE)
@@ -233,6 +196,7 @@ function refresh() {
233196
}
234197
235198
let onIframeLoad: (() => void) | undefined
199+
let stopLocationWatch: (() => void) | undefined
236200
237201
onMounted(() => {
238202
const existed = props.panes.has(paneKey.value)
@@ -255,9 +219,21 @@ onMounted(() => {
255219
})
256220
const iframe = pane.iframe
257221
258-
if (existed)
259-
updateCurrentUrl()
260-
else
222+
// Follow the frame wherever it goes — a document load, but also an SPA
223+
// router's `pushState`/`replaceState` and back/forward, none of which fire
224+
// `load`. `currentUrl` is the single source the address bar renders and the
225+
// session route persists, so tracking it here keeps both live. Reattaching
226+
// to an already-live pane reports its current href immediately if it moved
227+
// on since the last time this view watched it.
228+
stopLocationWatch = watchFrameLocation({
229+
iframe,
230+
initial: currentUrl.value,
231+
onChange: (href) => {
232+
currentUrl.value = href
233+
},
234+
})
235+
236+
if (!existed)
261237
// A freshly created pane is loading its initial content — reflect it so the
262238
// placeholder covers the first paint, not just later navigations.
263239
isIframeLoading.value = true
@@ -274,7 +250,6 @@ onMounted(() => {
274250
// Listen for iframe load events
275251
onIframeLoad = () => {
276252
isIframeLoading.value = false
277-
updateCurrentUrl()
278253
}
279254
iframe.addEventListener('load', onIframeLoad)
280255
@@ -326,6 +301,10 @@ onMounted(() => {
326301
327302
onUnmounted(() => {
328303
window.removeEventListener('message', onWindowMessage)
304+
// A shared frame outlives this view, so its page is left exactly as found —
305+
// the incoming view starts its own watch.
306+
stopLocationWatch?.()
307+
stopLocationWatch = undefined
329308
const pane = props.panes.get(paneKey.value)
330309
if (pane && onIframeLoad)
331310
pane.iframe?.removeEventListener('load', onIframeLoad)
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import type { FrameLocationTarget, FrameLocationWindow } from '../frame-location'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { watchFrameLocation } from '../frame-location'
4+
5+
type Listener = () => void
6+
7+
interface FakeFrame {
8+
iframe: FrameLocationTarget
9+
/** Emit a same-document navigation the way a router would. */
10+
pushState: (href: string) => void
11+
replaceState: (href: string) => void
12+
/**
13+
* Navigate without going through the frame's current `pushState` — a router
14+
* that captured the method before the watch attached, which no wrapper sees.
15+
*/
16+
pushStateBypassingWrapper: (href: string) => void
17+
/** Emit the Navigation API's post-commit notification. */
18+
emitCurrentEntryChange: () => void
19+
/** Emit a browser-driven same-document navigation. */
20+
emit: (type: 'popstate' | 'hashchange', href: string) => void
21+
/** Swap in a new document (new window, new `history`) and fire `load`. */
22+
load: (href: string) => void
23+
/** Whether the frame's `history` methods are the ones it started with. */
24+
isHistoryPristine: () => boolean
25+
/** Whether every listener the watcher attached has been removed. */
26+
isDetached: () => boolean
27+
}
28+
29+
/**
30+
* A frame standing in for a same-origin iframe. `navigation: true` gives it the
31+
* Navigation API on top of `history`, the way a browser that ships it does.
32+
*/
33+
function fakeFrame(initialHref: string, options: { navigation?: boolean, crossOrigin?: boolean } = {}): FakeFrame {
34+
const loadListeners = new Set<Listener>()
35+
let win: FrameLocationWindow
36+
let href = initialHref
37+
let winListeners: Map<string, Set<Listener>>
38+
let navListeners: Set<Listener>
39+
let pristinePush: (...args: any[]) => void
40+
let pristineReplace: (...args: any[]) => void
41+
42+
function createWindow(): void {
43+
const listeners = new Map<string, Set<Listener>>([['popstate', new Set()], ['hashchange', new Set()]])
44+
const nav = new Set<Listener>()
45+
const history = {
46+
pushState: (...args: any[]) => {
47+
href = String(args[2])
48+
},
49+
replaceState: (...args: any[]) => {
50+
href = String(args[2])
51+
},
52+
}
53+
pristinePush = history.pushState
54+
pristineReplace = history.replaceState
55+
winListeners = listeners
56+
navListeners = nav
57+
win = {
58+
get location() {
59+
if (options.crossOrigin)
60+
throw new DOMException('cross-origin', 'SecurityError')
61+
return {
62+
get href() {
63+
return href
64+
},
65+
}
66+
},
67+
history,
68+
navigation: options.navigation
69+
? {
70+
addEventListener: (_type, listener) => void nav.add(listener),
71+
removeEventListener: (_type, listener) => void nav.delete(listener),
72+
}
73+
: undefined,
74+
addEventListener: (type, listener) => void listeners.get(type)!.add(listener),
75+
removeEventListener: (type, listener) => void listeners.get(type)!.delete(listener),
76+
}
77+
}
78+
79+
createWindow()
80+
81+
const iframe: FrameLocationTarget = {
82+
get contentWindow() {
83+
return win
84+
},
85+
addEventListener: (_type, listener) => void loadListeners.add(listener),
86+
removeEventListener: (_type, listener) => void loadListeners.delete(listener),
87+
}
88+
89+
function emitCurrentEntryChange(): void {
90+
for (const listener of [...navListeners]) listener()
91+
}
92+
93+
return {
94+
iframe,
95+
emitCurrentEntryChange,
96+
pushState: (next) => {
97+
win.history!.pushState({}, '', next)
98+
},
99+
replaceState: (next) => {
100+
win.history!.replaceState({}, '', next)
101+
},
102+
pushStateBypassingWrapper: (next) => {
103+
pristinePush({}, '', next)
104+
emitCurrentEntryChange()
105+
},
106+
emit: (type, next) => {
107+
href = next
108+
for (const listener of [...winListeners.get(type)!]) listener()
109+
},
110+
load: (next) => {
111+
createWindow()
112+
href = next
113+
for (const listener of [...loadListeners]) listener()
114+
},
115+
isHistoryPristine: () =>
116+
win.history!.pushState === pristinePush && win.history!.replaceState === pristineReplace,
117+
isDetached: () =>
118+
loadListeners.size === 0
119+
&& navListeners.size === 0
120+
&& [...winListeners.values()].every(set => set.size === 0),
121+
}
122+
}
123+
124+
describe('watchFrameLocation', () => {
125+
it('reports pushState and replaceState by wrapping them, and restores them on dispose', () => {
126+
const frame = fakeFrame('http://localhost/app/')
127+
const onChange = vi.fn()
128+
const dispose = watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
129+
130+
expect(onChange).not.toHaveBeenCalled()
131+
expect(frame.isHistoryPristine()).toBe(false)
132+
133+
frame.pushState('http://localhost/app/routes')
134+
frame.replaceState('http://localhost/app/routes?tab=2')
135+
expect(onChange.mock.calls.map(c => c[0])).toEqual([
136+
'http://localhost/app/routes',
137+
'http://localhost/app/routes?tab=2',
138+
])
139+
140+
dispose()
141+
expect(frame.isHistoryPristine()).toBe(true)
142+
expect(frame.isDetached()).toBe(true)
143+
144+
frame.pushState('http://localhost/app/after-dispose')
145+
expect(onChange).toHaveBeenCalledTimes(2)
146+
})
147+
148+
it('the wrapper still performs the navigation it wraps', () => {
149+
const frame = fakeFrame('http://localhost/app/')
150+
watchFrameLocation({ iframe: frame.iframe, onChange: () => {}, initial: 'http://localhost/app/' })
151+
frame.pushState('http://localhost/app/moved')
152+
expect(frame.iframe.contentWindow!.location.href).toBe('http://localhost/app/moved')
153+
})
154+
155+
it('reports pushState where the Navigation API exists too, without depending on it', () => {
156+
// Whether `pushState` fires a Navigation API event is not something to bet a
157+
// stale address bar on, so the wrapper stays in place either way.
158+
const frame = fakeFrame('http://localhost/app/', { navigation: true })
159+
const onChange = vi.fn()
160+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
161+
162+
frame.pushState('http://localhost/app/routes')
163+
expect(onChange).toHaveBeenCalledTimes(1)
164+
expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/routes')
165+
})
166+
167+
it('catches a navigation the wrapper cannot see, via currententrychange', () => {
168+
const frame = fakeFrame('http://localhost/app/', { navigation: true })
169+
const onChange = vi.fn()
170+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
171+
172+
frame.pushStateBypassingWrapper('http://localhost/app/router-owned')
173+
expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/router-owned')
174+
})
175+
176+
it('reports a navigation heard from two sources once', () => {
177+
const frame = fakeFrame('http://localhost/app/', { navigation: true })
178+
const onChange = vi.fn()
179+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
180+
181+
// A browser where `pushState` does fire the Navigation API event: the
182+
// wrapper and the listener both report, and the href dedupe absorbs it.
183+
frame.pushState('http://localhost/app/routes')
184+
frame.emitCurrentEntryChange()
185+
expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/routes')
186+
})
187+
188+
it('reports back/forward and hash routing', () => {
189+
const frame = fakeFrame('http://localhost/app/')
190+
const onChange = vi.fn()
191+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
192+
193+
frame.emit('popstate', 'http://localhost/app/back')
194+
frame.emit('hashchange', 'http://localhost/app/back#section')
195+
expect(onChange.mock.calls.map(c => c[0])).toEqual([
196+
'http://localhost/app/back',
197+
'http://localhost/app/back#section',
198+
])
199+
})
200+
201+
it('re-subscribes after a document load, whose window and history are new', () => {
202+
const frame = fakeFrame('http://localhost/app/')
203+
const onChange = vi.fn()
204+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
205+
206+
frame.load('http://localhost/app/reloaded')
207+
expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/reloaded')
208+
209+
// The pre-load subscription died with the old window; only a fresh one on
210+
// the new `history` object keeps soft navigations reported.
211+
frame.pushState('http://localhost/app/reloaded/deep')
212+
expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/reloaded/deep')
213+
})
214+
215+
it('reports an already-navigated frame on attach, but never the blank placeholder', () => {
216+
const booting = fakeFrame('about:blank')
217+
const onBoot = vi.fn()
218+
watchFrameLocation({ iframe: booting.iframe, onChange: onBoot, initial: 'http://localhost/app/' })
219+
expect(onBoot).not.toHaveBeenCalled()
220+
221+
// A frame that soft-navigated while no view was watching it (a shared frame
222+
// between dock switches) is corrected as soon as the next watch attaches.
223+
const live = fakeFrame('http://localhost/app/elsewhere')
224+
const onAttach = vi.fn()
225+
watchFrameLocation({ iframe: live.iframe, onChange: onAttach, initial: 'http://localhost/app/' })
226+
expect(onAttach).toHaveBeenCalledExactlyOnceWith('http://localhost/app/elsewhere')
227+
})
228+
229+
it('reports each distinct href once', () => {
230+
const frame = fakeFrame('http://localhost/app/')
231+
const onChange = vi.fn()
232+
watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
233+
234+
frame.emit('popstate', 'http://localhost/app/same')
235+
frame.emit('popstate', 'http://localhost/app/same')
236+
expect(onChange).toHaveBeenCalledTimes(1)
237+
})
238+
239+
it('observes nothing on a cross-origin frame, and does not throw', () => {
240+
const frame = fakeFrame('http://elsewhere.test/app/', { crossOrigin: true })
241+
const onChange = vi.fn()
242+
const dispose = watchFrameLocation({ iframe: frame.iframe, onChange, initial: 'http://localhost/app/' })
243+
244+
expect(onChange).not.toHaveBeenCalled()
245+
expect(() => frame.load('http://elsewhere.test/app/other')).not.toThrow()
246+
expect(onChange).not.toHaveBeenCalled()
247+
expect(() => dispose()).not.toThrow()
248+
})
249+
250+
it('tolerates a frame with no contentWindow', () => {
251+
const onChange = vi.fn()
252+
const iframe: FrameLocationTarget = {
253+
contentWindow: null,
254+
addEventListener: () => {},
255+
removeEventListener: () => {},
256+
}
257+
expect(() => watchFrameLocation({ iframe, onChange })()).not.toThrow()
258+
expect(onChange).not.toHaveBeenCalled()
259+
})
260+
})

0 commit comments

Comments
 (0)