A Weird Imagination

Devlog: Folklife schedule user script (2 of 2): fighting React

Posted in

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:

  1. 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).
  2. Instead of moving the event blocks, clone them: td.appendChild(block.cloneNode(true)).
  3. Use the display style to hide the existing schedule's <div> instead of removing it: existingGrid.style.display = "none";.
  4. Set the overflow style so scrolling works normally: document.body.style.overflow = "scroll";
  5. Resize the outer <div> that is now mostly whitespace by using getBoundingClientRect() 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 classes 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.


  1. As I mentioned last time, this event ended before this post was published, so this link will likely be broken soon after this is published if it isn't already. 

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.