Skip to content

Add Shiny bindings and adopt htmlwidgets for visualization rendering#1

Merged
thomasp85 merged 55 commits into
posit-dev:mainfrom
cpsievert:feat/shiny-bindings
Apr 27, 2026
Merged

Add Shiny bindings and adopt htmlwidgets for visualization rendering#1
thomasp85 merged 55 commits into
posit-dev:mainfrom
cpsievert:feat/shiny-bindings

Conversation

@cpsievert
Copy link
Copy Markdown
Contributor

@cpsievert cpsievert commented Apr 20, 2026

Summary

  • Adds ggsqlOutput() / renderGgsql() / ggsql_session_reader() for
    embedding ggsql visualizations in Shiny apps.
  • Replaces the hand-rolled inline <script> + sprintf() HTML generation in
    the knitr engine with an htmlwidgets widget
    (ggsql_vega), which now handles all HTML rendering contexts: knitr chunks,
    print(), and Shiny.

Why htmlwidgets?

The old vegalite_html() path generated standalone JavaScript blobs for every
visualization — each one loaded Vega from a CDN, managed its own script
deduplication via a global promise, and implemented responsive scaling inline.
This worked for simple static documents but couldn't participate in Shiny's
output binding protocol, and the CDN dependency meant visualizations didn't
render offline or inside networks that block external scripts.

htmlwidgets gives us a standard rendering surface that works everywhere R
produces HTML. Vega, Vega-Lite, and Vega-Embed are now vendored as
htmlDependency objects under inst/htmlwidgets/lib/, so dependency
deduplication and script loading are handled by htmltools automatically. The
Shiny output binding comes for free from the htmlwidgets framework.

Shiny API

ggsqlOutput(outputId) / renderGgsql(expr) — standard Shiny
output/render pair. renderGgsql() accepts either a raw ggsql query string or
a pre-computed Spec. Query strings can use r:varname data references, which
resolve against the render expression's environment (not just knit_global()).

ggsql_session_reader(reader) — registers a session-scoped default reader
so you don't have to pass one to every renderGgsql() call. Cleaned up
automatically when the session ends.

library(shiny)
library(ggsql)

ui <- fluidPage(
  ggsqlOutput("chart")
)

server <- function(input, output, session) {
  ggsql_session_reader(duckdb_reader())

  output$chart <- renderGgsql({
    "SELECT * FROM r:mtcars VISUALISE mpg AS x, disp AS y DRAW point"
  })
}

shinyApp(ui, server)

Widget runtime

The browser-side code lives in srcts/ as TypeScript and is built to
inst/htmlwidgets/ggsql_vega.js. It handles:

  • Rendering Vega-Lite specs via vegaEmbed with responsive resize.
  • Compound sizing for multi-view and faceted specs, allocating viewport
    dimensions across sub-plots so they fill the host container.
  • Scale-to-fit when the container is narrower than the chart's minimum width.

Dropped: fig.cap and fig.align for interactive output

The old inline-HTML path supported fig.cap (wrapping output in a <figure> /
<figcaption>) and fig.align (CSS margin alignment) for the vegalite
writer. These are dropped for interactive HTML output, consistent with how
htmlwidgets are treated generally.

In practice, the previous fig.cap approach didn't work in Quarto anyway, since
Quarto manages figure environments and captions at a higher level and the raw
<figure> wrapper was invisible to it. The vegalite_svg and vegalite_png
writers still support fig.cap since their output passes through knitr's normal
graphics pipeline.

The engine now emits a warning if fig.cap is set with the vegalite writer,
directing users to the SVG/PNG writers for captioned figures.

Changes to knitr engine output

The engine's vegalite writer path now returns an htmlwidgets object instead of
a raw HTML string. This means sizing, dependency management, and responsive
behavior are delegated to the htmlwidgets framework and its sizing policy rather
than being implemented ad-hoc in inline JavaScript.

resolve_data_refs() gained an envir parameter (defaulting to
knit_global() in the engine, the render expression's environment in Shiny) so
the same resolution logic works in both contexts.

@cpsievert cpsievert force-pushed the feat/shiny-bindings branch from 3e7fc1c to 8d17b35 Compare April 21, 2026 00:19
@thomasp85
Copy link
Copy Markdown
Collaborator

This looks good - thanks a bunch. I did some work on the engine in the meantime to better support native knitr sizing options so you'll have to incorporate that. Otherwise it LGTM

cpsievert and others added 7 commits April 21, 2026 11:30
Adds ggsqlOutput/renderGgsql for rendering ggsql visualizations in Shiny
apps, backed by a <ggsql-viz> custom element that renders Vega-Lite specs
via vegaEmbed. Includes ggsql_session_reader() for managing DuckDB reader
lifecycle per session. All dependency scripts use defer for correct load
ordering. The Shiny output binding is jQuery-free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents where inst/lib/ JS files are sourced from (jsdelivr CDN)
and pins their versions for reproducible updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Custom elements default to display:inline, which silently ignores
width/height CSS. Move display:block into the component stylesheet
(along with overflow:hidden) so it applies in both knitr and Shiny
paths, and remove the now-redundant inline display:block from tag.R.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restore the responsive scaling behavior from the old vegalite_html:
render into an inner container with min-width 450px, and use a
ResizeObserver to apply transform:scale() when the host element is
narrower. Also add @ts-check + JSDoc annotations throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Drop the default `envir = knitr::knit_global()` so callers must pass
the environment explicitly. Also remove a stale section header and
document the GC-based reader cleanup in Shiny.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert force-pushed the feat/shiny-bindings branch from b5b040a to fb4c95b Compare April 21, 2026 16:44
cpsievert and others added 2 commits April 21, 2026 15:54
Replace hand-rolled HTML rendering (custom <ggsql-viz> element, manual
Shiny OutputBinding, htmltools tag building) with a standard htmlwidgets
widget. All HTML output now flows through ggsql_widget() which calls
htmlwidgets::createWidget().

This gives us bslib fill support, bindCache(), Shiny size reporting,
RStudio Viewer pane sizing, and saveWidget() potential with one
rendering path to maintain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpsievert cpsievert marked this pull request as draft April 21, 2026 23:14
@cpsievert cpsievert force-pushed the feat/shiny-bindings branch from 29b4275 to 5788e57 Compare April 22, 2026 00:10
cpsievert and others added 15 commits April 21, 2026 19:13
Reduces inline style churn in renderValue by using CSS classes for
display, overflow, alignment, and container sizing. Only truly dynamic
properties (aspect-ratio, scale transform) remain as inline styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dgets

- Remove `asp`, `caption`, and `align` params from `ggsql_widget()` and
  the JS custom element. knitr pre-computes fig.asp into fig.height, and
  htmlwidgets' `resolveSizing` handles width/height via `knitr.figure = TRUE`.
- Route both engine and knit_print.Spec through `knitr::knit_print(widget)`
  so htmlwidgets resolves sizing from knitr chunk options natively.
- Warn when `fig.cap` is used with the interactive vegalite writer, since
  custom engine output bypasses knitr's figure caption machinery.
- Attach CSS via explicit htmlDependency (top-level `stylesheet` YAML
  field is not read by htmlwidgets).
- Fix test helpers to work outside a real pandoc session
  (`screenshot.force = FALSE`, `rmarkdown.pandoc.to = "html"`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…"container"

Custom elements default to display:inline, which reports 0 for
clientWidth/clientHeight. In Shiny the CSS dependency loads after
renderValue runs, so Vega's ResizeObserver-based container sizing saw 0
and rendered a zero-width SVG.

The fix unifies the simple and compound spec paths: both now read
clientWidth/clientHeight and pass explicit pixel dimensions into the
spec. For simple specs, autosize: { type: "fit", contains: "padding" }
ensures the SVG fits within the given bounds. widget_html prepends
display:block inline so the element is block-level from first paint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Express all htmlwidget dependencies (vega, vega-lite, vega-embed,
ggsql-viz-sizing, ggsql-viz-styles) programmatically via
vegalite_dependencies() instead of the static YAML file. This makes
dependencies composable for future writer types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Separates third-party Vega libs (vega, vega-lite, vega-embed) from
ggsql's own widget assets (sizing JS, styles CSS) so future writers
can reuse one set without the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Detect facet/hconcat/vconcat/concat specs and compute explicit pixel
dimensions from the container size, with padding and legend heuristics
ported from querychat's Python implementation. Single-view specs
continue to use width/height: "container". On resize, compound specs
re-embed when width drifts >20%, otherwise use CSS transform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hain

Move the htmlwidgets JavaScript source from a single hand-edited .js file
into a TypeScript project under srcts/. Adds esbuild bundling, tsc type
checking, and vitest tests. Extracts compound chart sizing logic into its
own module and renames ggsql_viz -> ggsql_vega throughout.
…tBox

Fix compound chart sizing to detect row/column grid facets (not just
wrapped facets) and prefer clientHeight over inline style when reading
the host element bounding box.
Comment thread DESCRIPTION
Imports:
cli,
htmltools,
htmlwidgets,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, the next release of htmlwidgets will move rmarkdown from Suggests to Import

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... I guess this is not too horrible given the prevalence of RMarkdown and the fact that this package provides a knitr engine for it

Add srcts/, tools/check-js.mjs, and tools/update-vega.sh to .Rbuildignore
so they don't end up in the CRAN tarball.

Move Vega version strings into a generated R/vega-versions.R file that
tools/update-vega.sh writes automatically, so version numbers live in
one place instead of three.
The bin shim shadowed the real tsc binary to rewrite bare
`tsc tsconfig.json` into `tsc -p tsconfig.json`, but the typecheck
script already passes `-p` explicitly. Remove the wrapper and the
package.json bin field.
@cpsievert cpsievert changed the title Add Shiny bindings and <ggsql-viz> web component Add Shiny bindings and adopt htmlwidgets for visualization rendering Apr 24, 2026
@cpsievert cpsievert marked this pull request as ready for review April 24, 2026 15:54
@cpsievert
Copy link
Copy Markdown
Contributor Author

@thomasp85 ok, ready for your eyes now

@thomasp85 thomasp85 merged commit c0ba758 into posit-dev:main Apr 27, 2026
11 checks passed
@thomasp85
Copy link
Copy Markdown
Collaborator

Thanks for your work on this, Carson

@cpsievert cpsievert deleted the feat/shiny-bindings branch April 28, 2026 14:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants