posit-dev / posit-dev/shinychat
Bookmark restore drops render-hook environments: tool-result displays with live bslib/htmltools tags fail to re-render
Nobody has claimed this yet.
- 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
-
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: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 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 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_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
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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