Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 61 additions & 61 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/navigation/browserEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ interface NavigationEvent extends Event {
canIntercept: boolean
userInitiated: boolean
navigationType: string
destination: { url: string }
hashChange: boolean
destination: { url: string, sameDocument: boolean }
intercept: (options: { handler: () => Promise<void>, scroll: 'manual', focusReset: 'manual' }) => void
}

Expand Down Expand Up @@ -67,8 +68,17 @@ export function registerBrowserNavigation (controller: NavigationController): vo
// NAVIGATION API: Enter on the current URL (typically one ending in #me) is a same-URL
// "replace" navigation; intercepting it shows the URL without reloading. Without the API
// there is no equivalent, see the fallback above.
//
// Enter is either a fragment navigation (`hashChange`) or a cross-document one. A replacement
// to the current URL without a hash change is a `history.replaceState` write. Safari marks the
// application's own writes as `userInitiated` during a tap and can dispatch them after
// `writeNavigationHistory` returns, so that guard alone does not stop them. Use the URL and hash
// rather than `sameDocument` here because WebKit versions differ in whether they report that
// property for a history API write.
navigationApi.addEventListener('navigate', event => {
if (isWritingNavigationHistory(window.history) ||
(!event.hashChange && event.destination.sameDocument !== false &&
event.destination.url === window.location.href) ||
!event.canIntercept || !event.userInitiated || event.navigationType !== 'replace' ||
event.destination.url !== window.location.href) return

Expand Down
8 changes: 6 additions & 2 deletions test/unit/mainPage/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ describe('browser navigation without a reload', () => {
canIntercept: true,
userInitiated: true,
navigationType: 'replace',
destination: { url: window.location.href },
hashChange: true,
destination: { url: window.location.href, sameDocument: true },
intercept,
...overrides
})
Expand Down Expand Up @@ -211,7 +212,10 @@ describe('browser navigation without a reload', () => {
const GotoSubject = await setupLoaded({}, false)
window.history.replaceState({ paneName: 'folder', paneUri: `${origin}/other/` }, '', window.location.href)

const intercept = enter()
const intercept = enter({
hashChange: false,
destination: { url: window.location.href, sameDocument: false }
})
expect(intercept).toHaveBeenCalledOnce()
await intercept.mock.calls[0][0].handler()

Expand Down
61 changes: 45 additions & 16 deletions test/unit/navigation/browserEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,33 @@ async function freshModule () {
return import('../../../src/navigation/browserEvents')
}

// Enter on a URL ending in #me is a fragment navigation to the same URL
function enter (overrides: object = {}) {
const intercept = vi.fn()
listeners.navigate({
canIntercept: true,
userInitiated: true,
navigationType: 'replace',
destination: { url: window.location.href },
hashChange: true,
destination: { url: window.location.href, sameDocument: true },
intercept,
...overrides
})
return intercept
}

// What Safari dispatches for history.replaceState during a tap
function historyWrite (intercept: ReturnType<typeof vi.fn>) {
listeners.navigate({
canIntercept: true,
userInitiated: true,
navigationType: 'replace',
hashChange: false,
destination: { url: window.location.href, sameDocument: true },
intercept
})
}

const traverse = (navigationType = 'traverse') => listeners.currententrychange({ navigationType })
const nextTask = () => new Promise(resolve => setTimeout(resolve, 5))

Expand Down Expand Up @@ -64,11 +78,23 @@ describe('Enter on the current URL', () => {
expect(show).toHaveBeenCalledWith({ kind: 'restore', forceHeading: true })
})

it('also handles Enter on a URL without a fragment, which is a cross-document navigation', async () => {
const { registerBrowserNavigation } = await freshModule()
registerBrowserNavigation(controller)

expect(enter({ hashChange: false, destination: { url: window.location.href, sameDocument: false } })).toHaveBeenCalledOnce()
})

it.each([
['not started by the user', { userInitiated: false }],
['not a replacement of the entry', { navigationType: 'push' }],
['one that cannot be intercepted', { canIntercept: false }],
['to another URL', { destination: { url: 'https://elsewhere.example/' } }]
['to another URL', { destination: { url: 'https://elsewhere.example/', sameDocument: false } }],
['a history API write', { hashChange: false }],
['a history API write with same-document metadata omitted', {
hashChange: false,
destination: { url: window.location.href, sameDocument: undefined }
}]
])('leaves a navigation alone when it is %s', async (_name, overrides) => {
const { registerBrowserNavigation } = await freshModule()
registerBrowserNavigation(controller)
Expand All @@ -90,13 +116,7 @@ describe('application history writes in Safari', () => {
const original = window.history.replaceState.bind(window.history)
const intercept = vi.fn()
const replace = vi.spyOn(window.history, 'replaceState').mockImplementation((state, title, url) => {
listeners.navigate({
canIntercept: true,
userInitiated: true,
navigationType: 'replace',
destination: { url: window.location.href },
intercept
})
historyWrite(intercept)
original(state, title, url)
})
const subject = sym(window.location.href)
Expand All @@ -119,13 +139,7 @@ describe('application history writes in Safari', () => {
const original = window.history.replaceState.bind(window.history)
const nestedIntercept = vi.fn()
vi.spyOn(window.history, 'replaceState').mockImplementation((state, title, url) => {
listeners.navigate({
canIntercept: true,
userInitiated: true,
navigationType: 'replace',
destination: { url: window.location.href },
intercept: nestedIntercept
})
historyWrite(nestedIntercept)
original(state, title, url)
})

Expand All @@ -136,6 +150,21 @@ describe('application history writes in Safari', () => {
expect(show).toHaveBeenCalledOnce()
})

it('keeps the account-menu profile when Safari dispatches the write after it has returned', async () => {
const { registerBrowserNavigation } = await freshModule()
const { writeNavigationHistory } = await import('../../../src/navigation/state')
registerBrowserNavigation(controller)
const profileState = { paneName: 'profile', paneUri: 'https://pod.example/profile/card#me', viaAccountMenu: true }
const intercept = vi.fn()

writeNavigationHistory(window.history, 'replaceState', profileState, '')
historyWrite(intercept)

expect(intercept).not.toHaveBeenCalled()
expect(show).not.toHaveBeenCalled()
expect(window.history.state).toEqual(profileState)
})

it('clears the write guard even if history throws', async () => {
const { registerBrowserNavigation } = await freshModule()
const { writeNavigationHistory } = await import('../../../src/navigation/state')
Expand Down
Loading