Skip to content

Bookmark restore drops render-hook environments: tool-result displays with live bslib/htmltools tags fail to re-render #261

Description

@gadenbuie

Summary

shinychat serializes chat turns for bookmarking with jsonlite::serializeJSON() and restores them with jsonlite::unserializeJSON() (see client_get_state() / client_set_state() for the Chat method). That round-trip rebuilds any embedded function from deparsed source text, discarding its original environment.

If a tool result's extra$display contains a live htmltools/bslib tag — i.e. one carrying a render hook (tag$.renderHooks) — restoring the bookmark and re-rendering the tool card throws, because the hook can no longer resolve the package internals it closed over:

Error in tag_require_client_side: could not find function "tag_require_client_side"

The transcript then fails to re-render, so the restored session is broken.

Minimal reproducible example

This reproduces the failure with no LLM and no Shiny app — it mirrors exactly what client_get_state() / client_set_state() do to a tool result whose display embeds a bslib::input_code_editor() (a Bootstrap web component, which attaches a render hook):

library(ellmer)

req <- ContentToolRequest(id = "call_1", name = "demo", arguments = list())
res <- ContentToolResult(
  value   = "done",
  request = req,
  extra   = list(display = list(
    html = bslib::input_code_editor(id = "x", value = "select 1", language = "sql")
  ))
)

# Mirror shinychat's bookmark round-trip
recorded   <- contents_record(res)
state_json <- jsonlite::serializeJSON(recorded)
restored   <- contents_replay(jsonlite::unserializeJSON(state_json))

htmltools::findDependencies(restored@extra$display$html)
#> Error in tag_require_client_side: could not find function "tag_require_client_side"

The same thing happens in a real app: enable bookmarking, have a tool produce a result whose display$html/display$footer contains such a tag, reload to restore — the error fires from client_set_ui()chat_append_message()as.tags.shinychat_tool_card()htmltools::findDependencies().

Even more minimal, the core fragility is just a JSON round-trip of any bslib web component:

ce  <- bslib::input_code_editor(id = "x", value = "select 1", language = "sql")
ce2 <- jsonlite::unserializeJSON(jsonlite::serializeJSON(ce))
htmltools::findDependencies(ce2)
#> Error: could not find function "tag_require_client_side"

Root cause

  1. bslib::input_code_editor() (and other Bootstrap components) returns a tag with a render hook in tag$.renderHooks. htmltools runs render hooks lazily at render time, i.e. when as.tags() / findDependencies() / renderTags() walk the tag. The hook's body calls bslib internals:

    function(x) {
      current_version <- theme_version(bs_current_theme())
      if (isTRUE(current_version < version)) stop(...)
      return(tag_require_client_side(x, version, caller))   # unexported bslib internal
    }

    Normally this resolves fine because the hook is a closure whose environment chains to bslib's namespace.

  2. client_get_state() records turns with ellmer::contents_record() and serializes with jsonlite::serializeJSON(), which deparses the closure to source text.

  3. client_set_state() restores with jsonlite::unserializeJSON(), which parse()s the text back into a function whose environment is now R_GlobalEnv — the captured variables (version, caller) and the link to bslib's namespace are gone.

  4. On re-render, as.tags.shinychat_tool_card() calls htmltools::findDependencies() on display$html / display$footer, which runs the hook. Exported bslib functions (theme_version, bs_current_theme) still resolve via the attached search path, but the unexported tag_require_client_side does not → "could not find function".

This is not specific to input_code_editor — any tag carrying a namespace-bound render hook (many bslib components, htmlwidgets, etc.) embedded in a tool-result display will hit it. It surfaces only on restore, because the hook fires when the stored display is re-rendered, not when the result is first produced. (Discovered via querychat's visualization tool, which embeds a bslib::input_code_editor() in its tool-result display footer.)

Options to consider

A few directions for discussion — not a recommendation:

  1. Freeze tool-result display tags before serialization. In client_get_state() (or when recording the display), resolve render hooks to static HTML + dependencies (e.g. htmltools::renderTags()HTML() + html_dependency list) so only inert, serialization-safe values enter the bookmark. Keeps web-component markup and JS deps intact for client-side hydration. Trade-off: hooks run at bookmark time, outside the live session/theme, so the resolved dependency/version context may differ from render time.

  2. Avoid serializeJSON/unserializeJSON for the parts that may hold tags. For server-side bookmark stores, a serializer that preserves environments (e.g. saveRDS/serialize) round-trips closures correctly. Doesn't help URL bookmarks (size + must be text), and changes the storage format.

  3. Re-home rebuilt closures on restore. After unserializeJSON(), deserialized render-hook functions could have their environment reset to a context where package internals resolve. Fragile in general (can't know the right namespace), but might be scoped to known tag shapes.

  4. Document/validate the constraint. Treat live tags with render hooks in extra$display as unsupported for bookmarking and warn (or freeze) at record time, pushing producers toward markdown/text displays or pre-rendered HTML.

Stack trace (from the original app-level report)

Full stack trace
Warning: Error in tag_require_client_side: could not find function "tag_require_client_side"
...
141: hook [<text>#8]
140: as.tags.shiny.tag
...
127: htmltools::findDependencies
126: as.tags.shinychat_tool_card
125: htmltools::as.tags
124: split_html_islands
...
112: chat_append_message
...
90: method(client_set_ui, new_S3_class(c("Chat", "R6")))
88: client_set_ui
...
51: observe

Environment

  • shinychat 0.4.0.9000
  • ellmer 0.4.1
  • bslib (current), htmltools (current)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Priority: HighHigh-confidence regression or severe bug affecting many users or current release work.ai-triage:doneMarks an issue whose AI triage workflow is complete.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions