refreshless-8.x-1.x-dev/modules/refreshless_turbo/js/browser_fixes/redirect_url_race.js
modules/refreshless_turbo/js/browser_fixes/redirect_url_race.js
/**
* @file
* Redirect URL race condition fix/workaround.
*
* This is notably a problem in Firefox where everything on Turbo's side appears
* to be called in the correct sequence, but a history.pushState() very quickly
* followed by a history.replaceState() can result in the URL being incorrectly
* left on the pre-redirect URL in this sequence:
*
* 1. Redirecting URL
* 2. Redirected to URL (briefly visible)
* 3. Redirected URL
*
* The third item is unexpected, and not seen in Chrome.
*
* This is reproducible on the /admin/compact link on /admin/config, and seems
* to occur regardless of network latency, i.e. can be seen both in a local DDEV
* site and one running on a remote server. It's unclear if this only happens
* with GET request redirects, as POST redirects in Drupal aren't as obvious or
* common.
*
* @see https://bugzilla.mozilla.org/buglist.cgi?quicksearch=replaceState
* Does not currently yield a specific report of this issue but worth checking
* in the future.
*/
(function(html, $, once, Turbo) {
'use strict';
/**
* Our event namespace.
*
* @type {String}
*
* @see https://learn.jquery.com/events/event-basics/#namespacing-events
*/
const eventNamespace = 'refreshless-turbo-firefox-redirect-race-fix';
/**
* Our once() identifier.
*
* @type {String}
*/
const onceName = eventNamespace;
/**
* Property name on the HTML element to save the class to.
*
* @type {String}
*/
const propName = eventNamespace;
/**
* Duration to watch for mismatched Turbo and window location after a load.
*
* Note that this starts at a 'refreshless:load' after a
* 'refreshless:redirect', and not at the 'refreshless:redirect' event which
* triggers much earlier.
*
* @type {Number}
*/
const watchDuration = 100;
/**
* Watches for and attempts to fix incorrect URLs after a redirect.
*/
class RedirectUrlRaceFixer {
/**
* Duration to watch for mismatched Turbo and window location after a load.
*
* @type {Number}
*/
#duration;
/**
* The window.location object to check against.
*
* @type {Location}
*/
#location;
/**
* A timeout ID or null if one isn't active.
*
* @type {Number|Null}
*/
#timeoutId = null;
/**
* Hotwire Turbo instance.
*
* @type {Turbo}
*/
#Turbo;
constructor(duration, location, Turbo) {
this.#duration = duration;
this.#location = location;
this.#Turbo = Turbo;
}
/**
* Destroy this instance.
*/
destroy() {
this.stop();
}
/**
* Start watching.
*/
async start() {
if (this.isWatching() === true) {
this.stop();
}
this.#timeoutId = setTimeout(() => {
// Doesn't actually need to do anything other than expire.
}, this.#duration);
const turboHistory = this.#Turbo.navigator.history;
while (this.isWatching() === true) {
// Wait for at least one frame to be rendered between checks so that we
// don't hog the main thread.
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
if (turboHistory.location.href === this.#location.href) {
continue;
}
this.stop();
turboHistory.update(
history.replaceState, turboHistory.location,
turboHistory.restorationIdentifier,
);
}
}
/**
* Stop watching.
*/
stop() {
if (this.isWatching() !== true) {
return;
}
clearTimeout(this.#timeoutId);
this.#timeoutId = null;
}
/**
* Whether we're currently watching.
*
* @return {Boolean}
* True if watching; false otherwise.
*/
isWatching() {
return this.#timeoutId !== null;
}
}
$(once(
onceName, html,
)).on(`refreshless:redirect.${eventNamespace}`, (event) => {
if (typeof $(event.target).prop(propName) !== 'undefined') {
$(event.target).prop(propName).destroy();
}
$(event.target)
.off(`refreshless:load.${eventNamespace}`)
.one(`refreshless:load.${eventNamespace}`, (event) => {
$(event.target).prop(propName, new RedirectUrlRaceFixer(
watchDuration, window.location, Turbo,
));
$(event.target).prop(propName).start();
});
});
})(document.documentElement, jQuery, once, Turbo);
