Skip to content

feat(hub): track iframe soft navigations so the address bar and session route stay live - #252

Merged
antfu merged 4 commits into
mainfrom
feat/frame-location-sync
Aug 17, 2026
Merged

feat(hub): track iframe soft navigations so the address bar and session route stay live#252
antfu merged 4 commits into
mainfrom
feat/frame-location-sync

Conversation

@antfu

@antfu antfu commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The gap

An iframe dock's address bar only refreshed on the frame's load event. An embedded SPA moving between routes with history.pushState() fires no load, so the bar — and the selectedDockRoute persisted from it — kept showing whatever URL the frame booted with. A reload then restored the developer to the dock's entry point instead of where they actually were.

The change

watchFrameLocation() (new, @devframes/hub/client) reports a same-origin frame's location.href on every navigation it can observe. ViewIframe feeds it into the currentUrl the address bar already renders and the session route already persists — so this reuses the one source rather than adding a second tracking path. updateCurrentUrl() and its load-only refresh are gone.

Observation sources overlap deliberately instead of being chosen between, since reports are deduped by href — hearing a navigation twice costs nothing, missing one costs a stale route:

Source Covers
history.pushState / replaceState, wrapped in place the SPA case; these fire no event of their own
Navigation API currententrychange what a wrapper structurally cannot see — a router calling a pushState reference captured before the watch attached
popstate / hashchange back/forward and hash routing
load document navigations, and re-subscribing — a document navigation replaces the frame's history/navigation objects

Disposing restores any wrapped method, so the embedded page is left exactly as found — which matters because a shared frame outlives the view watching it.

A cross-origin frame reports nothing (its location is unreadable by design) and the caller keeps the last URL it knew, as before. about:blank is never reported, so a booting frame can't overwrite a real route with a blank.

On navigatesuccess

I first reached for navigatesuccess, but neither MDN nor the WICG explainer states whether pushState fires it — so the implementation doesn't depend on it. The history wrapper is the deterministic path in every engine, and currententrychange is the complement. Chromium does fire currententrychange for pushState; the dedupe absorbs the double report.

Verification

  • 11 unit tests (packages/hub/src/client/__tests__/frame-location.test.ts), stub-injected in the style of frame-nav.test.ts.
  • Real-browser check against the built dist in Chromium — real iframe, real same-origin SPA, real pushState/replaceState/popstate/hashchange/document load — run both as the browser ships and with navigation removed from the guest to exercise the wrapper-only path other engines take. All checks pass in both modes; the wrapper-bypassing case is correctly reported with the Navigation API and correctly unobservable without it. (Harness kept out of the repo — it needs a browser download CI doesn't have here.)
  • pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build all clean (the 3 remaining lint warnings are pre-existing in wire-codec.ts).

Drive-by

ViewIframe had its own copy of the remote-connection stripper; it now uses the hub's exported stripRemoteConnectionFromUrl, which additionally strips the descriptor out of a hash-route query (#/route?devframe-remote=…) that the local copy leaked into the address bar. The persisted route deliberately keeps the descriptor, since a restored iframe still has to connect.

API surface

Additive to @devframes/hub/client: watchFrameLocation plus its option/target interfaces. Snapshot updated.

🤖 Generated with Claude Code

…on route stay live

An iframe dock's address bar only updated on the `load` event, so an embedded
SPA moving between routes with `history.pushState()` left it — and the
`selectedDockRoute` persisted from it — showing whatever URL the frame booted
with. A reload then restored the developer to the dock's entry point rather than
where they actually were.

`watchFrameLocation()` reports a same-origin frame's `location.href` on every
navigation it can observe, and `ViewIframe` feeds it into the `currentUrl` the
address bar already renders and the session route already persists — one source,
now live, rather than a second tracking path.

The observation sources overlap deliberately instead of being chosen between,
because reports are deduped by href: `pushState`/`replaceState` are wrapped in
place (restored on dispose) since they fire no event of their own, the Navigation
API's `currententrychange` also catches what a wrapper structurally cannot — a
router calling a `pushState` reference captured before the watch attached — and
`popstate`/`hashchange` cover back/forward and hash routing. `load` re-subscribes,
since a document navigation replaces the frame's `history`/`navigation` objects.

Verified in Chromium against the built dist, both with the Navigation API and
with it removed to exercise the wrapper-only path other engines take.

Also drops `ViewIframe`'s local copy of the remote-connection stripper for the
hub's exported `stripRemoteConnectionFromUrl`, which additionally strips the
descriptor out of a hash-route query (`#/route?devframe-remote=…`) the local copy
leaked into the address bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 17, 2026 05:33
@netlify

netlify Bot commented Aug 17, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

Name Link
🔨 Latest commit a8528aa
🔍 Latest deploy log https://app.netlify.com/projects/devfra/deploys/6a82aee2a3cf6f0008e80d9d
😎 Deploy Preview https://deploy-preview-252--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds same-origin iframe “soft navigation” tracking to keep the hub iframe dock address bar and persisted session route in sync with SPAs that navigate via history.pushState() / replaceState() (no load event).

Changes:

  • Added watchFrameLocation() to @devframes/hub/client to observe iframe navigations via History wrappers + Navigation API + events (popstate/hashchange/load) with href dedupe.
  • Updated ViewIframe to use watchFrameLocation() as the single source for currentUrl, and to use the shared stripRemoteConnectionFromUrl() for address-bar sanitization.
  • Added unit tests and updated the tsnapi API snapshot for the new exported surface.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/snapshots/tsnapi/@devframes/hub/client.snapshot.js Updates JS snapshot to include the new watchFrameLocation export.
tests/snapshots/tsnapi/@devframes/hub/client.snapshot.d.ts Updates type snapshot with new location-watching interfaces and function signature.
packages/hub/src/client/index.ts Re-exports the new frame-location module from the hub client entrypoint.
packages/hub/src/client/frame-location.ts Implements watchFrameLocation() with multi-source navigation observation + cleanup/restore behavior.
packages/hub/src/client/tests/frame-location.test.ts Adds unit tests for the watcher behavior (wrapping, dedupe, resubscribe on load, cross-origin tolerance).
packages/hub-ui/src/client/components/views/ViewIframe.vue Switches iframe URL tracking to watchFrameLocation() and consolidates URL sanitization via stripRemoteConnectionFromUrl().
Suppressed comments (3)

packages/hub/src/client/tests/frame-location.test.ts:172

  • toHaveBeenCalledExactlyOnceWith is not a built-in Vitest/Jest matcher in this repo, so this assertion will throw and fail the test suite.
    expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/router-owned')

packages/hub/src/client/tests/frame-location.test.ts:184

  • toHaveBeenCalledExactlyOnceWith is not a built-in Vitest/Jest matcher in this repo, so this assertion will throw and fail the test suite.
    expect(onChange).toHaveBeenCalledExactlyOnceWith('http://localhost/app/routes')

packages/hub/src/client/tests/frame-location.test.ts:225

  • toHaveBeenCalledExactlyOnceWith is not a built-in Vitest/Jest matcher in this repo, so this assertion will throw and fail the test suite.
    expect(onAttach).toHaveBeenCalledExactlyOnceWith('http://localhost/app/elsewhere')

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/hub/src/client/__tests__/frame-location.test.ts Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 17, 2026 05:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

packages/hub/src/client/tests/frame-location.test.ts:164

  • These assertions are missing the usual indentation, which will fail formatting/lint checks and makes the test block harder to read.
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenLastCalledWith('http://localhost/app/routes')

Copilot AI review requested due to automatic review settings August 17, 2026 06:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@antfu
antfu merged commit c2f7c2f into main Aug 17, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants