Skip to content

Instantly share code, notes, and snippets.

@kch
Last active August 6, 2026 04:08
Show Gist options
  • Select an option

  • Save kch/e87a380698464db6fa4102449eeb45db to your computer and use it in GitHub Desktop.

Select an option

Save kch/e87a380698464db6fa4102449eeb45db to your computer and use it in GitHub Desktop.
Change the URL with pushState/replaceState in Safari and the cursor under the user's stationary pointer is swapped for an arrow, staying wrong until they move the mouse. The WebKit cause, the dead ends, and a fix that works, with a demo.

Safari swaps the cursor out from under a stationary pointer when the URL changes

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.

Why the page cannot simply ask for the right cursor

WebCore re-resolves which cursor belongs under the pointer in exactly three places:

  1. a real platform mouse event (EventHandler::mouseMoved)
  2. a fake mouse move WebCore schedules itself, 100 to 250ms after a scroll
  3. 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.

The measurements

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.

The fix

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.

What does not work

Worth writing down, because most of it looks like it should:

  • Walking style.cursor off its value and back in one go. Both writes coalesce into a single style recalc, so the diff RenderElement watches 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, toggling pointer-events, calling elementFromPoint, moving the cursor property 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.

Upstream

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.

Reproducing it

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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Safari swaps the cursor under a still pointer: demo</title>
<style>
:root { color-scheme: light dark; --line: #ddd; --dim: #767676; --panel: #f4f4f5; }
@media (prefers-color-scheme: dark) { :root { --line: #3a3a3e; --dim: #9a9a9a; --panel: #202024; } }
body { font: 15px/1.55 -apple-system, system-ui, sans-serif; margin: 0 auto; padding: 28px 22px 60vh; max-width: 780px; }
h1 { font-size: 21px; margin: 0 0 6px; }
h2 { font-size: 15px; margin: 30px 0 10px; }
p { margin: 0 0 12px; }
.dim { color: var(--dim); }
.rule { background: var(--panel); border-left: 3px solid #999; padding: 11px 14px; margin: 0 0 22px; border-radius: 0 4px 4px 0; }
code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--panel); padding: 1px 4px; border-radius: 3px; }
.exp { border-top: 1px solid var(--line); padding: 11px 0 10px; }
.head { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; }
.tag { flex: 0 0 16px; font-weight: 700; color: var(--dim); font-variant-numeric: tabular-nums; }
/* The experiment: every one of these asks for the hand. Click, hold still, and
see whether the hand survives. */
button { cursor: pointer; font: inherit; font-size: 14px; font-weight: 600; padding: 8px 12px;
width: 290px; text-align: left; border: 1px solid #bbb; border-radius: 6px;
background: #fff; color: inherit; }
@media (prefers-color-scheme: dark) { button { background: #2a2a2e; border-color: #555; } }
button:hover { border-color: #888; }
button.control { border-color: #a33; }
button.fix { border-color: #2d7a3e; }
.note { color: var(--dim); font-size: 12.5px; margin: 5px 0 0 25px; }
.status { font-size: 12.5px; font-weight: 600; }
#log { margin-top: 24px; border-top: 1px solid var(--line); padding-top: 12px; color: var(--dim);
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Safari swaps the cursor out from under a still pointer</h1>
<p class="dim">Open this in Safari on macOS. In other browsers every button below behaves
identically and nothing is demonstrated.</p>
<div class="rule">
Every button asks for the hand cursor (<code>cursor: pointer</code>). Click one and
<b>hold the mouse perfectly still</b>. If the hand turns into an arrow while the pointer
is still over the button, the cursor parked. Moving the mouse is itself what un-parks it,
so the observation has to be made before you touch anything.
</div>
<div id="experiments"></div>
<h2>What you should see</h2>
<p class="dim">2 parks and 3 does not, which is what shows the trigger is the address bar
being redrawn rather than the history entry being written. 6 parks for a frame or two and
recovers, because nothing can pre-empt the park and the first cursor change after it wins.
8 stays parked for a full three seconds and then recovers on cue, which is the same fact
stated slowly.</p>
<div id="log"></div>
<script>
// One offscreen element whose cursor value gets alternated. Created once and left
// alone, so the first flip is a real diff against a committed style. WebKit schedules
// its global cursor update from RenderElement, and only when an element's computed
// cursor differs from the one it was last laid out with. Two writes inside a single
// task coalesce into one style recalc and produce no diff at all, which is exactly why
// walking style.cursor off its value and back does nothing.
const kicker = document.createElement("div");
kicker.style.cssText = "position:fixed;left:-10px;top:-10px;width:1px;height:1px;cursor:progress";
document.body.append(kicker);
const kickOnce = () => {
kicker.style.cursor = kicker.style.cursor === "wait" ? "progress" : "wait";
};
// Every frame for 400ms, rather than a guess at when the park lands. The flips before
// it are deduped against the live cursor and lost; the first one after it repairs.
function kick(ms = 400) {
const until = performance.now() + ms;
const flip = () => {
kickOnce();
if (performance.now() < until) requestAnimationFrame(flip);
};
requestAnimationFrame(flip);
}
// A real scroll position change, which WebKit answers 100 to 250ms later with a fake
// mouse move of its own: cursor, hover and mousemove listeners all re-run.
function nudge() {
window.scrollBy(0, 1);
requestAnimationFrame(() => window.scrollBy(0, -1));
}
// Absolute, and built from location.href rather than from a path. A relative URL
// resolves against the document's base URL, and a page served through a previewer
// (htmlpreview and friends) carries a <base> pointing at wherever the file really
// lives. Resolve against that and replaceState throws SecurityError for a
// cross-origin URL, silently, on every click: no URL change, no park, nothing to
// see. Keeping whatever query the host already had also leaves its own pointer
// intact.
let n = 0;
const BASE = location.href.split("#")[0].replace(/[?&]n=\d+/, "");
const next = () => BASE + (BASE.includes("?") ? "&" : "?") + "n=" + (++n);
const EXPERIMENTS = [
{ id: 1, name: "baseline, no URL write", cls: "",
note: "Nothing should happen. If this parks, the cause is not the URL at all.",
run: () => {} },
{ id: 2, name: "replaceState, URL changes", cls: "control",
note: "The bug. Expect the hand to become an arrow and stay one.",
run: () => history.replaceState({}, "", next()) },
{ id: 3, name: "replaceState, URL identical", cls: "",
note: "State changes, the address bar does not. Surviving here is what proves the trigger is the address bar redraw and not the history entry.",
run: () => history.replaceState({ n: Date.now() }, "", location.href) },
{ id: 4, name: "pushState, URL changes", cls: "",
note: "Same question, other method.",
run: () => history.pushState({}, "", next()) },
{ id: 5, name: "location.hash", cls: "",
note: "No history API at all.",
run: () => { location.hash = "h" + (++n); } },
{ id: 6, name: "replaceState + cursor kick", cls: "fix",
note: "The fix: alternate an offscreen element's cursor every frame for 400ms.",
run: () => { history.replaceState({}, "", next()); kick(); } },
{ id: 7, name: "replaceState + scroll nudge", cls: "fix",
note: "The alternative: scroll 1px and back, which goes through WebKit's own fake mouse move. Restores hover too, but shimmies the page.",
run: () => { history.replaceState({}, "", next()); nudge(); } },
{ id: 8, name: "park now, kick after 3s", cls: "fix",
note: "Whether a kick rescues a cursor that is already parked. One click does both: any second input would end the experiment, since moving the mouse un-parks it and Tab hides the pointer outright.",
run: (el) => {
history.replaceState({}, "", next());
const status = el.querySelector(".status");
let left = 3;
status.textContent = "kick in 3…";
const tick = setInterval(() => {
status.textContent = --left > 0 ? `kick in ${left}…` : "kicked, look now";
if (left > 0) return;
clearInterval(tick);
kickOnce();
say(" kicked, 3s after the park");
}, 1000);
} },
];
const host = document.getElementById("experiments");
for (const exp of EXPERIMENTS) {
const el = document.createElement("div");
el.className = "exp";
el.innerHTML =
`<div class="head"><span class="tag">${exp.id}</span>` +
`<button class="${exp.cls}">${exp.name}</button><span class="status"></span></div>` +
`<div class="note">${exp.note}</div>`;
el.querySelector("button").addEventListener("click", () => {
const was = location.href;
say(stamp() + " " + exp.id + " " + exp.name);
try { exp.run(el); } catch (e) { say(" threw: " + e.name + " " + e.message); }
// Said outright, because an experiment whose URL did not move proves nothing and
// looks exactly like one that proved the cursor survives.
say(location.href === was ? " url UNCHANGED" : " url changed");
moved = false;
});
host.append(el);
}
const log = document.getElementById("log");
const stamp = () => new Date().toTimeString().slice(0, 8);
const say = (line) => { log.textContent += line + "\n"; };
// Marks where an experiment ended, since the first real pointer move is itself what
// re-resolves the cursor. Once per run, or the log would be nothing else.
let moved = false;
addEventListener("mousemove", () => {
if (moved) return;
moved = true;
say(" <- mouse moved, cursor re-resolves here");
}, { passive: true });
say("ready. click one, hold still, watch the cursor.");
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment