If your page updates the address bar without navigating, history.pushState or
history.replaceState, Safari replaces the mouse cursor with the default arrow and will
not reconsider until it sees a real mouse event.
The damage is in the timing. A cursor is supposed to describe whatever is under the pointer, and it changes when the pointer moves. This changes it while the pointer is perfectly still, which is exactly when the user has no reason to expect it and no way to trigger a correction: they clicked something and are looking at the result. Whatever they clicked, a sort header, a filter chip, a tab, is still a link, still hoverable, and now wears an arrow. It reads as disabled. Moving the mouse a single pixel fixes it, so the bug is invisible to anyone who fidgets and permanent for anyone who does not.
Everything below was measured in Safari, not inferred.
2-demo.html in this gist is the test page it was measured with. A gist serves raw files
as text/plain with nosniff, so it cannot host a live page itself; the shortest way to
run it is
htmlpreview,
which fetches the raw text and injects it into a document of its own. Note that the demo
changes the URL, which is the entire subject, so it overwrites htmlpreview's own query
string: the page goes on working, but reloading after a click loses the pointer to the
file. Saving the file and opening it from a local server avoids that.
WebCore re-resolves which cursor belongs under the pointer in exactly three places:
- a real platform mouse event (
EventHandler::mouseMoved) - a fake mouse move WebCore schedules itself, 100 to 250ms after a scroll
EventHandler::scheduleCursorUpdate(), which re-hit-tests at the last known pointer position with no mouse event at all
The third one has a single caller in web content: RenderElement, when an element's
computed cursor value differs between two committed styles. That is the whole opening,
and the fix below is built on it.
Note what it means: the update re-hit-tests wherever the pointer already is. The element
whose cursor changed does not have to be the element under the pointer.
Nothing in the same-document navigation path touches the cursor, so the park itself is
Safari's UI layer resetting NSCursor while it redraws the address bar. The web process
never re-asserts, because none of the three conditions fired.
| result | |
|---|---|
replaceState, URL changes |
parks |
replaceState, URL byte-identical |
survives |
pushState |
parks |
location.hash |
parks |
flip a cursor before the park |
swallowed |
| flip after the park, however late | repairs |
| flip every frame for 400ms | repairs, flicker barely perceptible |
Three conclusions.
Only a visible URL change parks it. replaceState that leaves the address bar
byte-identical does not park. So this is the address bar redrawing, not the history
entry being written, and changing state alone is free.
Nothing pre-empts the park. It happens asynchronously in the UI process, and
PageClientImpl::setCursor dedups against the live NSCursor, so a fix that lands
before the park is discarded and the park wins anyway.
Any cursor diff after the park repairs it, however late. Parked deliberately, then left alone for three seconds, the cursor came back the instant one landed.
Which means: do not try to prevent it. Repair it, as soon after as possible.
Keep one 1px element offscreen and alternate its cursor on every frame for a few
hundred milliseconds after the URL write. The flips before the park are wasted; the
first one after it wins.
const kicker = document.createElement("div")
kicker.style.cssText =
"position:fixed;left:-10px;top:-10px;width:1px;height:1px;cursor:progress"
document.body.append(kicker)
// Safari only. Everywhere else the cursor was never parked and this is a style
// flip per frame for nothing.
const parksTheCursor = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
function kickCursor(ms = 400) {
if (!parksTheCursor) return
const until = performance.now() + ms
const flip = () => {
kicker.style.cursor = kicker.style.cursor === "wait" ? "progress" : "wait"
if (performance.now() < until) requestAnimationFrame(flip)
}
requestAnimationFrame(flip)
}
history.replaceState({}, "", nextUrl)
kickCursor()The two cursor values are arbitrary. Only the diff matters, and it has to survive a rendering update to be seen at all, which is why this alternates across frames instead of writing twice in a row.
Worth writing down, because most of it looks like it should:
- Walking
style.cursoroff its value and back in one go. Both writes coalesce into a single style recalc, so the diffRenderElementwatches for never exists. This is the most convincing dead end. - Deferring the URL write by a timeout. It postpones the arrow, nothing more.
- Re-setting the element's
href, togglingpointer-events, callingelementFromPoint, moving thecursorproperty to<body>. - Dispatching a synthetic
mousemove. It is untrusted, and the platform cursor is driven only by real events. - Waiting for a real pointer event before writing the URL. It does avoid the park, and it is what I shipped first, but it trades the bug for a worse one: the address bar now lags. Anyone who reaches for ⌘L, ⌘R, duplicate-tab or copy-link without moving the mouse gets the previous view. Keyboard users get it every time.
One alternative does work: scroll by 1px and back. That goes through WebKit's own fake mouse move, so it restores cursor and hover together. It also shimmies the page, so it is a fallback rather than a first choice.
WebKit bug 53340, filed January 2011, currently reopened, is the underlying "cursor is stale until the mouse moves" design. A fix landed 2025-03-11 as PR #40887, updating the cursor on every layout, and was reverted 2025-04-01 for a power regression (bug 290658). Blink has the same design (Chromium 41031275).
Jake Archibald reported the same symptom after view transitions
(public-css-archive, Nov 2024):
hovered items stay hovered and cursor is not recomputed until the pointer moves.
Source worth reading:
EventHandler.cpp
for the three paths,
RenderElement.cpp
for the scheduleCursorUpdate call,
PageClientImplMac.mm
for the dedup that explains why an early flip is lost.
A static page is enough: a button with cursor: pointer, a replaceState on click, and
your own eyes. Click, hold the mouse perfectly still, and watch the pointer.
Two things make this awkward to test and easy to get wrong. Moving the mouse to inspect anything is itself the thing that un-parks the cursor, so the observation has to be made before touching the mouse. And pressing Tab hides the pointer outright in Safari, so a keyboard-driven variant of the experiment cannot be watched either. Anything that needs a second input has to be driven by the page: park, then flip on a timer, and watch through the delay.