Adventure into Shinylive

code
rstats
webasm
Author

Jared Norman

Published

July 7, 2024

1 Problem Setup

We’re giving a workshop next week in a place where the internet frequently drops. Our workshop uses an R Shiny app with some R code powering the visualisatons which we want attendees to engage with and work through. Thinking through our options we felt that before investigating installable shiny servers, it would be great if shinylive just worked out of the box. So we sought to try it out.

2 Will it just work?

In our first attempt we were optimistic and simply followed the instructions on the main page of the documentation which amount to:

  1. use the shinylive package to create the webasm assets which a user can then download and run locally
  2. host these assets somehow (eg httpuv) and point a browser to them

Unfortunately we got this relatively opaque error:

The dreaded Robj error. I later learned that this came up because I had binary files in my repository and shinylive was not dealing with them.

Devtools wasn’t too helpful:

There are a lot of “preload error” messages which, in typical R style, are not actually errors. Then there is our actual error referencing Robj. Finally an invitation to use AI to explore the error.

The shinyjs line where the Robj error is thrown isn’t really useful - just a big try-catch which attempts to run the app so the actual error doesn’t seem too traceable in a JS context:

Result of setting a breakpoint and rerunning. Not much context.

For what it’s worth, the e.stack reads:

Error: Robj construction for this JS object is not yet supported
    at newObjectFromData (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:3299:9)
    at new _RObject (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:3327:14)
    at http://127.0.0.1:7446/shinylive/webr/webr-worker.js:3634:41
    at Array.forEach (<anonymous>)
    at new RList (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:3633:14)
    at newRObject (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:4635:15)
    at PostMessageChannelWorker.dispatch (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:4469:29)
    at PostMessageChannelWorker.<anonymous> (http://127.0.0.1:7446/shinylive/webr/webr-worker.js:3024:44)

It’s not clear what object is failing to be parsed, nor at which line of code this error occurs. At this point I decided to tackle the problem on the weekend1.

3 Draw the rest of the owl

With errors like this, I like to incorporate learnings on the efficiency of divide-and-conquer techniques. I ask if we can bisect the inputs to see which portion of them is responsible for the undesired output. This is essentially what a binary search does and is a useful debugging technique2.

Whenever you have a set of inputs which you can arrange such that asking each input a question results in “no” for the first \(k\) inputs then “yes” for the remaining ones, you can use a binary search. In this case the question is “does it break?” and the inputs start with a minimal working example and end with the current code base3. The binary search method in general comes with a fair bit of wisdom attached. For example sometimes the question can be costly and there are ways to deal with that as is the case here.

After a while though I realised that I’m up against an important gotcha: cache! I suddenly started getting the error for even the simplest shiny app and realised somewhere in the process caching was happening. It was not clear if this was happening with the assets I was compiling with shinylive::export since I saw there’s a shinylive::assets_cleanup with a reference to cache I flagged that as a potential source of issues without reading further4.

The more likely culprit was the browsers cache however. Reloading the assets without cache takes quite a while unfortunately so the test-fail-fix-repeat loop is suboptimal. I looked at some of the assets which are coming down and I suspect not everything needs its browser cache invalidated to allow for a valid debugging methodology5. Regardless, it’s better to start with something that works and increment on that in this situation because at least that way you can see if the modification worked or if you’re on a cached version.

I was able to successfully use the bslib package with page_navbar() ui and basic plot() of reactively generated fake data. To update the app, my workflow is to run shinylive::export(".", "site");httpuv::runStaticServer("site").

When I moved to include the tibble package and replace my fake data with a tibble, I found some errors lighting up but no practical change in app behaviour:

Including the tibble package gave some possible errors. After refreshing the browser cache however, the warnings about LazyFiles went away.

The next thing to try is including an image. It looks like the second you place a png file into the repository the app gives the Robj error seen above6. The png file comes into site/app.json as an asset which is base64 encoded (with some \n chars in there). From my testing I learned that removing the png json object from site/app.json results in a not-opaquely-breaking app.

Snippet from the site/app.json file which includes a png

I could not find another app.json to compare against online but I did find an issue with the current quarto extension saying that binaries weren’t working.

After some reading of the documentation I learned that:

Each version of the Shinylive R package is associated with a particular version of the Shinylive web assets. (See the releases here.)

Shinylive version 1.1.0 is pinned to assets 0.2.3. In 0.2.4 it talks about fixing handling of binary files in appCode which you can more learn about here so I was hopeful that replacing the assets used by the package would result in the issue being resolved. It did not.

At this point I was many tribal councils in and I needed to find a way to stop shaving yaks and make progress. I decided to bring binary files in by base64 encoding them and saving them as text. Then, in R, I can convert them back and use as needed. When doing this for my png file, I uncovered a bug. In R, the following scheme works:

  1. write the file manually as a base64 encoded text file: base64enc::base64encode("path/to/my.png") |> write("fname.txt")
  2. bring the file in a workable format in R - eg for img as base64 format data: img_src <- as.character(paste0('data:image/png;base64,', readLines("fname.txt"), collapse=""))
  3. use as needed: tags$img(src=img_src)

The problem is that in my local version of R, readLines on a single-line file ending in \r\n gives a single element character vector. In the shinylive version of R, however, it gives a character vector were the second element is empty (I think, I didn’t investigate fully). I opted to go with img_src <- as.character(paste0('data:image/png;base64,', readLines("logo_masha.txt")[[1]], collapse="")) so it works in both local and shinylive versions. I suspect that little differences like this will continue to be discovered, especially since the technology is so early in its development.

I am able to source R scripts just fine but when it came to bringing in packages I did hit some issues. Not all packages are supported and in particular deSolve is not supported which we use to solve the model. I was able to use the r-wasm site to figure out which packages are supported. I found that I can replace deSolve with PBSDdesolve as a near drop-in replacement so with a little work I was easily able to modify the code to bring in supported packages.

And, eventually, things started rendering.

The rest of the owl

A key learning from this experience was to start small and build up rather than start big and strip away. Also, on my Windows machine with the most recent shinylive, binary files are not supported with no useful error explaining this.

Footnotes

  1. I did this while watching Survivor so it actually didn’t feel like work.↩︎

  2. In fact it’s a useful technique for many things in life and I often think about it. When you can reshape something into a general framework or approach such as binary search, you can use the efficiencies of that approach and pick up some of the wisdom acquired from thinking about that approach for free.↩︎

  3. This is actually the exact approach behind the git bisect method except the units are commits. I like to treat the situation a little more fluidly and take a somewhat more calculated stab at where to bisect but the approach is essentially the same.↩︎

  4. Note to self though, docs↩︎

  5. To update the cache for just app.json, simply load it in another tab. Eg: http://127.0.0.1:7446/app.json and force-reload with ctrl+shift+f5 or whatever hard-reset shortcut your browser uses.↩︎

  6. It turns out this is the case for any binary file. And the issue isn’t that the base64 is split by \n because even a tiny binary file without a split gives the issue.↩︎