posit-dev / posit-dev/shinychat

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

Open
#261 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

ai-triage:done Priority: High
Dominant language
TypeScript
Stars
139
Forks
28
Avg merge
23h 44m
Merged PRs (30d)
50

Description

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)

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the Chat methods client_get_state() and client_set_state(), then follow the reported client_set_ui() → chat_append_message() → as.tags.shinychat_tool_card() path. Reproduce the jsonlite round-trip with the bslib::input_code_editor() example and determine the supported handling for render-hook tags. Done means restored tool-result displays re-render without the tag_require_client_side error, with coverage for bookmark restoration.

Written by the indexing model from the issue text.

Assessment

Tech stack
r
Domain
frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.