Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save DavidJCobb/ac17988b02cab5d7b028fd133647f432 to your computer and use it in GitHub Desktop.
Save DavidJCobb/ac17988b02cab5d7b028fd133647f432 to your computer and use it in GitHub Desktop.

When viewing code files on GitHub, you can click on a symbol (i.e. identifier) to see a list of places where the symbol is mentioned (i.e. declarations, definitions, references). You can click on any listed location to jump to that location (file and line). On destop, this listing is shown in a sidebar that can be toggled on and off. On mobile, it appears in a dialog.

Unfortunately, on mobile, closing the dialog resets your scroll position to the line of code that you originally opened the dialog from. This is due to mistakes in the dialog implementation:

  • The dialog uses a useFocusTrap React hook (in node_modules/@primer/react/lib-esm/hooks/useFocusTrap.js) to trap focus. When the hook is first installed, it checks what element currently has focus, so that when the dialog is dismissed, it can return focus to that element. The hook doesn't know or care whether code on the page has deliberately scrolled to something else while the focus trap is active; during cleanup, the hook will always re-focus the element that had focus before the hook activated. This is what causes the page to forcibly scroll back to the line that originally opened the dialog.

  • Even if you find a way to kill useFocusTrap's forced focus on cleanup, the CodeNavInfoPanel (in app/assets/modules/react-code-view/components/blob/BlobContent/CodeNav/CodeNavInfoPanel.tsx) specifically has an onClose function which attempts to focus #symbols-button, forcing the scroll position to the top of the page. This button, at the top of the code listing, toggles visibility of the symbol sidebar on desktop. (This focus behavior would make sense when displaying the symbols pane as a sidebar, but is, frankly, dumb when displaying it as a dialog that opens when clicking anywhere in the code view.) The function in question is a React prop, so it's not even defined as part of CodeNavInfoPanel; you have to dig upward through the component's users to figure out where the function is even defined (InnerPanelContent in app/assets/modules/react-code-view/components/PanelContent.tsx).

(The source code links above will probably 404 if accessed directly, but those are the URLs that you'll see in browser devtools, based on GitHub's source maps.)

This issue was reported to GitHub at the start of 2023. It affects desktop versions of Chrome and Firefox when they emulate mobile view via devtools, and it affects Chromium on mobile. As of this writing, it still hasn't been fixed.

// ==UserScript==
// @name GitHub mobile fix for symbols dialog
// @version 1
// @grant none
// @run-at document-start
// @inject-into page
// @match https://*.github.com/*
// ==/UserScript==
let fix_installed = false;
function install_fix() {
if (fix_installed)
return;
fix_installed = true;
// GitHub's dialogs use a "useFocusTrap" React hook, which relies on useEffect
// to trap focus in the dialog. When the dialog component is dismissed and the
// hook is cleaned up, they attempt to return focus to the previously focused
// element. This causes us to scroll back to whatever line we initially clicked
// on to open the code-nav-info dialog.
//
// Ergo, we'll kill the ability to focus the previous element. Of course, it's
// not terribly easy to *find* that element, so we'll brute-force this.
{
let orig = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function(...args) {
if (this instanceof HTMLElement) {
if (
this.id == "read-only-cursor-text-area"
|| this.classList.contains("react-code-line-container")
)
return;
}
return orig.apply(this, args);
};
}
// The above code isn't, on its own, sufficient to fix this bug: we won't scroll
// back to the line we opened the dialog on, but we will scroll back to the top
// of the document. That's because the code-nav-info dialog's onClose handler
// wants to scroll to #symbols-button, the button at the top of the code view
// which toggles the Symbols sidebar.
//
// Hide the "symbols" button while the dialog is open, so it can't be scrolled to.
{
let style = document.createElement("style");
style.innerHTML = `
body:has([role="dialog"] #symbols-pane) #symbols-button {
display: none !important;
}
`;
document.documentElement.append(style);
}
console.log("[Userscript: GitHub mobile fix for symbols dialog] Fix installed");
}
{
// NOTE: Use of a MutationObserver like this will probably cause mild slowdowns
// on any page where we never tear down the observer (i.e. any page that isn't
// a code file). I could polish this up, but GitHub's website is aggressively
// terrible on mobile just by itself, so something like this is hardly going
// to make it *noticeably* worse.
let observer = new MutationObserver(function(records) {
for(const record of records) {
for(const node of record.addedNodes) {
if (!(node instanceof Element))
continue;
if (node.id === "symbols-button") {
observer.disconnect();
install_fix();
return;
}
}
}
});
observer.observe(document.documentElement, {
subtree: true,
childList: true,
});
// Check if the node already exists; if so, don't wait for the observer.
// (We register the observer first, and have a run-once flag on the
// function, to deal with race conditions between elements loading and
// us executing.)
let node = document.getElementById("symbols-button");
if (node) {
observer.disconnect();
install_fix();
} else {
console.log("[Userscript: GitHub mobile fix for symbols dialog] Fix pending");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment