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
-
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.
-
client_get_state() records turns with ellmer::contents_record() and serializes with jsonlite::serializeJSON(), which deparses the closure to source text.
-
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.
-
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:
-
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.
-
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.
-
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.
-
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)
Summary
shinychatserializes chat turns for bookmarking withjsonlite::serializeJSON()and restores them withjsonlite::unserializeJSON()(seeclient_get_state()/client_set_state()for theChatmethod). That round-trip rebuilds any embedded function from deparsed source text, discarding its original environment.If a tool result's
extra$displaycontains 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: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 abslib::input_code_editor()(a Bootstrap web component, which attaches a render hook):The same thing happens in a real app: enable bookmarking, have a tool produce a result whose
display$html/display$footercontains such a tag, reload to restore — the error fires fromclient_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:
Root cause
bslib::input_code_editor()(and other Bootstrap components) returns a tag with a render hook intag$.renderHooks. htmltools runs render hooks lazily at render time, i.e. whenas.tags()/findDependencies()/renderTags()walk the tag. The hook's body calls bslib internals:Normally this resolves fine because the hook is a closure whose environment chains to bslib's namespace.
client_get_state()records turns withellmer::contents_record()and serializes withjsonlite::serializeJSON(), which deparses the closure to source text.client_set_state()restores withjsonlite::unserializeJSON(), whichparse()s the text back into a function whose environment is nowR_GlobalEnv— the captured variables (version,caller) and the link to bslib's namespace are gone.On re-render,
as.tags.shinychat_tool_card()callshtmltools::findDependencies()ondisplay$html/display$footer, which runs the hook. Exported bslib functions (theme_version,bs_current_theme) still resolve via the attached search path, but the unexportedtag_require_client_sidedoes 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 abslib::input_code_editor()in its tool-result display footer.)Options to consider
A few directions for discussion — not a recommendation:
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_dependencylist) 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.Avoid
serializeJSON/unserializeJSONfor 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.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.Document/validate the constraint. Treat live tags with render hooks in
extra$displayas unsupported for bookmarking and warn (or freeze) at record time, pushing producers towardmarkdown/textdisplays or pre-rendered HTML.Stack trace (from the original app-level report)
Full stack trace
Environment