The problem#
Last time, I built a user script that could run on the Folklife 2024 schedule page1 and reorganize it so it would display the schedule as a grid. But it was brittle and awkward to use because it requiring careful ordering of the interactions with the page and reloading to view a different day's schedule.
The solution#
After failing to come up with an appropriate place to add an event handler, I gave up and took a different approach. I modified the script to work fine if it's run multiple times (and exit quickly if there's no work to do), even if the page is not in a valid state, and then simply set it to rerun every half second. Definitely a hack, but it worked.
Here's the final version of the user script and the Git repo showing the version history.
The details#
Failing to attach an event handler#
The normal way the page works is that there's a schedule for one day displayed and there's a drop-down box with a list of days of the event. If the user selects a different day, the page will query the server for that day's schedule information and show a loading animation while it waits for the response and then renders that day's schedule.
I already had a hook on the fetch()
call to get the schedule data,
although if the user switches days multiple times and goes back to one
they've already viewed, the page is smart enough to not make another
fetch()
call, so simply watching fetch()
is insufficient to detect
when a new day's schedule should be displayed.
Adding an event listener to drop-down seemed simple enough: just find
the <select>
element and listen for changes. But
there was no <select>
element: where I expected to see one, there's
actually a bunch of <span>
s and a hidden <input>
that form some
kind of React custom component. I made some attempts to add a listener
to the <input>
, but nothing ever triggered. But also, even if I could
detect when the user requested a new day, the script shouldn't actually
run until the new day's schedule has been rendered, and I didn't
know how to listen for that. (It looks like there is an API called
MutationObserver
but I did not try to use it.)
Since I couldn't figure out a good way to chose when to run the script,
I decided to fall back to just automatically retrying regularly using
setInterval()
and seeing if I could rewrite the script
so nothing would go wrong if it got called at a bad time.
Not breaking the page#
My initial version took away the <div>
with the schedule display and
built a new <table>
, grabbing the elements for the events from inside
the original <div>
and putting them into my <table>
. This made the
page very unhappy: after doing that, changing the day just resulted in
a white screen as it errored out looking for the elements my script had
moved around.
The workaround I came up with was to try to avoid modifying the existing
elements as much as possible and just hide them, so my <table>
could
take their place visibly but not mess up the DOM hierarchy. This
required a few changes:
- Make the
<table>
just be a child of<body>
instead of nesting it inside the<div>
s created by the page:document.body.appendChild(newGrid)
. - Instead of moving the event blocks, clone them:
td.appendChild(block.cloneNode(true))
. - Use the
display
style to hide the existing schedule's<div>
instead of removing it:existingGrid.style.display = "none";
. - Set the
overflow
style so scrolling works normally:document.body.style.overflow = "scroll";
- Resize the outer
<div>
that is now mostly whitespace by usinggetBoundingClientRect()
to find the bottom of the element I do want still visible and make that the entire height:
document.getElementById("__next").style.height =
document.querySelectorAll(".layout-row")[0]
.getBoundingClientRect().bottom + "px";
That was enough to get the <table>
visible and placed approximately
where the original schedule had been, while not making the page script
crash when changing days.
Where's my styles?#
Well, it was visible, but it wasn't styled as I expected, and I couldn't
figure out why. In the dev tools, I could see the CSS selectors used to
determine the styles, and I was copying the relevant classes, so I
expected the same styles to get applied to my copy of the schedule.
Upon further investigation, I noticed the styles weren't coming from a
.css
file. Firefox reported the source as inline:1
linking to an
empty sheet in the Style Editor table. Chromium reported the source as
<style>
, linking to the following line in the HTML source:
<style data-styled="active" data-styled-version="6.1.1"></style>
which apparently means that the styles are hidden
inside the React styled-components
library.
So, apparently, I had done too good a job of hiding my <table>
from
React and it was accidentally hiding from the styling magic it was
applying. I decided to try to move it to a DOM hierarchy that looked as
similar as possible to the real one without actually reusing any of
those elements. The outermost <div>
used an id
, so I couldn't copy
that, but everything else only used class
es to identify them, so I
could copy those. So I copied the nested chain of <div>
s containing
the original schedule and put that next to the original ones in that
outer <div>
but with the new <table>
where the original schedule had
been:
let node = existingGrid.parentNode;
let prevNode = null;
while (node != document.body) {
const newNode = document.createElement("div");
// Set newContainer to the innermost <div>,
// we'll put the <table> there.
if (!newContainer) newContainer = newNode;
else newNode.appendChild(prevNode);
newNode.className = node.className;
// Parent of the outermost div is <body>, stop there.
if (node.parentNode == document.body) {
node.appendChild(prevNode);
}
prevNode = newNode;
node = node.parentNode;
}
This successfully tricked React into applying its styles to my
<table>
, albeit a little too well: the table layout got messed up
and it took me a bit to realize it was because I had to override the
page's styles and explicitly set the display
style on the
<tr>
and <td>
nodes back to their defaults: tr.style.display =
"table-row"
and td.style.display = "table-cell"
.
Minimizing work#
While I now had a script that would display the table as expected and not break after changing the day to display, I didn't want to have to manually click a button to rebuild the table or, with the timer running the code every half second, be constantly rebuliding the table. But I also wanted to avoid the script getting stuck in an invalid state.
To get the day that should be displayed, I read it out of the current value of the dropdown:
const day = document.querySelector(".rs-picker-toggle-value").innerText;
Then I maintain a collection of functions that build the table for each day:
if (buildTable[day]) {
buildTable[day]()
return
}
// Otherwise still need to initialize buildTable[day]...
The first time it tries to display a day, it will fall-through to
initializing buildTable[day]
(and display a loading message instead
of confusing the user by keeping the previous day's schedule visible),
but afterwards it will immediately call the already set-up function
to build the table for the current day. Also, that function, once
it completes with no errors, sets a script variable marking the day
currently successfully displayed:
currentTableDay = error ? null : day;
The first thing buildTable[day]
does is check if there's actually any
work to do:
if (day == currentTableDay) return;
From there, it just required some care to make sure currentTableDay
gets set to null
on all of the error paths, so whenever the script is
called before the schedule for a day is fully built, it will know it
didn't succeed and retry the next time.
A hollow victory over one React page#
And with that, I was able to make the page look like it was intended to display with a schedule grid, properly updating when you switch between days.
But nothing here is particularly generalizable to any more complicated web page. It requires having a clear piece of screen real estate you can replace entirely while dodging React's claim of ownership over pieces of the DOM. Hopefully I've at least documented some pitfalls in a useful way.
Comments
Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.
There are no comments yet.