Commit 28a96914 authored by Robert Knight's avatar Robert Knight

Add NavigationObserver utility to observe client-side navigations

This utility monitors for same-document navigations that update the path
or query string of the URL but don't load a new Document.

This uses the Navigation API if available (Chrome >= 102) or falls back
to a combination of the "popstate" event and monkey-patching the
`window.history` API otherwise (all other browsers).
parent 02d96b84
import { ListenerCollection } from '../../shared/listener-collection';
/**
* Monkey-patch an object to observe calls to a method.
*
* The observer is not notified if the method throws.
*
* @template {any} T
* @param {T} object
* @param {keyof T} method
* @param {(...args: unknown[]) => void} handler - Handler that is invoked
* after the monitored method has been called.
* @return {() => void} Callback that removes the observer and restores `object[method]`.
*/
function observeCalls(object, method, handler) {
const origHandler = object[method];
/* istanbul ignore next */
if (typeof origHandler !== 'function') {
throw new Error('Can only intercept functions');
}
// @ts-expect-error
object[method] = (...args) => {
const result = origHandler.call(object, ...args);
handler(...args);
return result;
};
return () => {
object[method] = origHandler;
};
}
/** @param {string} url */
function stripFragment(url) {
return url.replace(/#.*/, '');
}
/**
* Utility for detecting client-side navigations of an HTML document.
*
* This uses the Navigation API [1] if available, or falls back to
* monkey-patching the History API [2] otherwise.
*
* Only navigations which change the path or query params are reported. URL
* updates which change only the hash fragment are assumed to be navigations to
* different parts of the same logical document. Also Hypothesis in general
* ignores the hash fragment when comparing URLs.
*
* [1] https://wicg.github.io/navigation-api/
* [2] https://developer.mozilla.org/en-US/docs/Web/API/History
*/
export class NavigationObserver {
/**
* Begin observing navigation changes.
*
* @param {(url: string) => void} onNavigate - Callback invoked when a navigation
* occurs. The callback is fired after the navigation has completed and the
* new URL is reflected in `location.href`.
* @param {() => string} getLocation - Test seam that returns the current URL
*/
constructor(
onNavigate,
/* istanbul ignore next - default overridden in tests */
getLocation = () => location.href
) {
this._listeners = new ListenerCollection();
let lastURL = getLocation();
const checkForURLChange = (newURL = getLocation()) => {
if (stripFragment(lastURL) !== stripFragment(newURL)) {
lastURL = newURL;
onNavigate(newURL);
}
};
// @ts-expect-error - TS is missing Navigation API types.
const navigation = window.navigation;
if (navigation) {
this._listeners.add(navigation, 'navigatesuccess', () =>
checkForURLChange()
);
} else {
const unpatchers = [
observeCalls(window.history, 'pushState', () => checkForURLChange()),
observeCalls(window.history, 'replaceState', () => checkForURLChange()),
];
this._unpatchHistory = () => unpatchers.forEach(cleanup => cleanup());
this._listeners.add(window, 'popstate', () => checkForURLChange());
}
}
/** Stop observing navigation changes. */
disconnect() {
this._unpatchHistory?.();
this._listeners.removeAll();
}
}
import { NavigationObserver } from '../navigation-observer';
function runTest(initialURL, callback) {
const onNavigate = sinon.stub();
const getURL = sinon.stub().returns(initialURL);
const observer = new NavigationObserver(onNavigate, getURL);
try {
callback(observer, onNavigate, getURL);
} finally {
observer.disconnect();
}
}
describe('NavigationObserver', () => {
context('when the Navigation API is supported', () => {
it('reports navigation when a "navigatesuccess" event fires', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
window.navigation.dispatchEvent(new Event('navigatesuccess'));
assert.calledWith(onNavigate, 'https://example.com/page-2');
});
});
it('stops reporting navigations after observer is disconnected', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
observer.disconnect();
window.navigation.dispatchEvent(new Event('navigatesuccess'));
assert.notCalled(onNavigate);
});
});
});
context('when the Navigation API is not supported', () => {
let origNavigation;
before(() => {
origNavigation = window.navigation;
window.navigation = null;
});
after(() => {
window.navigation = origNavigation;
});
it('reports navigation when `history.pushState` is called', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
window.history.pushState({}, null, location.href /* dummy */);
assert.calledWith(onNavigate, 'https://example.com/page-2');
});
});
it('reports navigation when `history.replaceState` is called', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
window.history.replaceState({}, null, location.href /* dummy */);
assert.calledWith(onNavigate, 'https://example.com/page-2');
});
});
it('reports navigation when a "popstate" event fires', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
window.dispatchEvent(new Event('popstate'));
assert.calledWith(onNavigate, 'https://example.com/page-2');
});
});
it('stops reporting navigations after observer is disconnected', () => {
runTest('https://example.com/page-1', (observer, onNavigate, getURL) => {
getURL.returns('https://example.com/page-2');
observer.disconnect();
window.history.pushState({}, null, location.href /* dummy */);
assert.notCalled(onNavigate);
});
});
});
[
// Path change
{
oldURL: 'https://example.com/path-1',
newURL: 'https://example.com/path-2',
shouldFire: true,
},
// Query param change
{
oldURL: 'https://example.com/path?page=1',
newURL: 'https://example.com/path?page=2',
shouldFire: true,
},
// Hash fragment change
{
oldURL: 'https://example.com/path#section-1',
newURL: 'https://example.com/path#section-2',
shouldFire: false,
},
{
oldURL: 'https://example.com/path#section-1',
newURL: 'https://example.com/path',
shouldFire: false,
},
].forEach(({ oldURL, newURL, shouldFire }) => {
it('only fires an event if the path or query params change', () => {
runTest(oldURL, (observer, onNavigate, getURL) => {
getURL.returns(newURL);
window.navigation.dispatchEvent(new Event('navigatesuccess'));
if (shouldFire) {
assert.calledWith(onNavigate, newURL);
} else {
assert.notCalled(onNavigate);
}
});
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment