Skip to content

Bookmarkable state

Joe Cheng edited this page Mar 29, 2016 · 3 revisions

(Some code is sketched out at @joe/feature/bookmarkable-state)

Goal

Let users bookmark/share a URL that reflects the current state of the app.

Notable challenges

  1. Dynamic UI (renderUI) makes it impossible to isolate a single moment when an app is "restoring". Nor can we just generate HTML with restored values during ui.R processing and expect to be done.
  2. An app's true state may not be 100% reflected in the inputs alone--there may be reactive values or variables, or any other side effects that occurred due to observe/observeEvent.

Prior art

shinyURL

  • Pro: Nice user experience
  • Pro: Easy to use--just drop in one function call to ui and one to server
  • Pro/Con: Works with any input that can process a "value" message
  • Pro/Con: Has found a hack to get it to work with renderUI (the first time, and only the first time, a particular input is seen by the server, it's overridden; not before or after)
  • Con: Only suitable for fast apps, as all outputs have to render before input values are restored to the values in the URL
  • Con: Limited space available in URL
  • Con: Looks a little clunky to see the default value appear in the input, then flash over to the correct URL-specified value later. Especially noticeable when the app has expensive outputs.

Radiant

  • Pro: Very general, can be used to save or restore any state in an app (as far as I can see)
  • Pro: Can handle unbounded amounts of data (not limited by URL length--I think?)
  • Pro: The state of an app is a first class object that can be passed around, (de)serialized
  • Con: Not clear how robust it is--not sure it works with multiple sessions in the same process (as rstate appears to be a true global)
  • Con: Requires more manual intervention in that every value parameter in every input widget function call must be annotated with state_single(id, defaultValue) or similar

Proposed solution

Apps will run in one of three modes--the app author needs to tell us what mode the app should run in. (Open issue: the API for setting the mode. It'll have to be in global.R since the mode affects the execution of both UI and server.)

  1. No restore. (Today's behavior)
  2. URL serialization: State is stored in query params; each value is JSON encoded, so we preserve datatypes (at the cost of a few extra characters).
  3. Server serialization: State serialized on the server, with a single query param to identify the restore record. (How the data will actually be stored on the server, for desktop use, Shiny Server, Shiny Server Pro, and Connect, are open questions. Especially need to think through security implications for the latter two.)

User experience

Like shinyURL, let user add some kind of UI control that will generate a URL and maybe copy it to the clipboard (and/or put it in the URL bar).

For URL serialization mode, an additional option might keep the URL always up-to-date as inputs change, using window.history.replaceState(null, null, queryString). This has the nice effect of making browser reload not lose all your state, same with navigating away and back.

Restore contexts

Wherever xxxInput functions can be invoked (ui.R and renderUI mostly, but theoretically from reactives and observers as well), we will have a restore context object available globally via a function (in much the same way that reactive domain objects are available globally using getDefaultReactiveDomain()).

A restore context is an object from which you can read three different classes of values:

  1. Saved input values (indexed by id)
  2. Saved non-input values (i.e. app author can add custom data at snapshot time to be available on restore. Not sure if URL serialization should support this.) (indexed by name)
  3. Saved files (similar to #2 but for data that should not be loaded into memory) (server serialization mode only)

Restore-aware inputs

A restore-aware input is an input function that knows how to restore itself from a restore context by reading input values that correspond with its inputId. Such functions have a restoreContext parameter that defaults to getDefaultRestoreContext() (name pending--getDefaultRestoreContext is a little long). The user can set this value to NULL to opt a particular invocation out of automatic restoration.

The body of the restore-aware input will have a line that looks for an input value; if one is present, it will be used instead of the provided default. And if not, and a reactive domain object (i.e. session) is available (i.e. not in ui.R), then the current session's input will be consulted next (with isolate()). And finally, if that value is not present either, then the default value is used.

textInput <- function(inputId, label, value = "", width = NULL, placeholder = NULL, restoreContext = getDefaultRestoreContext()) {
  value <- restoreInput(id = inputId, defaultValue = value, restoreContext = restoreContext)
  ...
}

(We will reach out to 3rd party input function authors that we know of and encourage them to make their inputs restore-aware. For those that don't comply, app authors can manually call restoreInput() in the same way.)

Wiring up the restore process

App authors will need to change their ui definitions from being UI objects, to being function(req) { ... } that return UI objects. This is because the rendering process needs to be influenced by each session's restore context.

Shiny will make sure to deserialize data from the URL or server, and set that as the restore context, before running the UI function.

The same will need to happen whenever any reactive domain is entered; we'll need the current restore context to be based off the current session. We could hardcode this into withReactiveDomain(), or maybe domains could have enter/exit handlers? (Probably not too important either way)

Saving and restoring non-input state

Some apps have important app state that's not reflected in the input values, e.g. "click a data point to exclude it", "which of these three buttons was most recently pressed", etc.

We won't be able to save/restore this kind of state automatically; instead, the app author will need to tell us what to do via callbacks.

server <- function(input, output, session) {
  excludedPoints <- c()
  makeReactiveBinding("excludedPoints")

  setStateCallbacks(  # need better function name
    onSave = function() {
      # No need to save input$xxx, it's done automatically
      # User returns (named) values and files somehow, API TBD.
      # For this strawman proposal, just return list
      list(
        values = list(
          excludedPoints = excludedPoints
        )
      )
    },
    onRestore = function(restoreContext) {
      excludedPoints <<- restoreContext$values$excludedPoints
    }
  )
  
  ...
}

Modules

It should work with modules. :)

The xxxInput functions should "just work" I believe. The restore context objects should be passed straight through AFAICT.

I'm not as confident about the custom callbacks; these are scoped by name, so it seems like at least part of the restore context will need to be namespace-aware.

Maybe the upshot is that fetching input values from a restore context is non-namespace-aware, but fetching custom values and files is namespace-aware.

Tabs, navbar page, navlist

For the purposes of bookmarkable state, these are just the same as any other inputs. They will need explicit id set, but other than that it should just work as normal.

TODO

  • How to restore a fileInput? (both visually and getting input$file to have the right contents)
  • Can we restore plot brushes? Leaflet most-recent-click?
  • What happens as app evolves (new inputs added)? Do we care? Are there best practices we can share?