Skip to content

Commit c97f85f

Browse files
committed
Fix DuckDB-WASM client mode in Shiny apps
In Shiny, htmlDependency scripts are dynamically inserted via document.createElement(), so document.currentScript is null. This broke WASM/worker path detection and Parquet URL resolution. Fixes: - Add duckdb-locator.js as a separate script that runs before the webpack bundle, with querySelector fallback for when currentScript is null - Add runtime querySelector fallback in Reactable.js useEffect for both wasmBasePath and parquetUrl resolution (DOM is populated by then) - Fix Parquet locator script to also use querySelector fallback - Suppress false positive preRenderHook warning when user explicitly sets mode='client' (only warn for auto-resolved client mode) Test updates: - Add R test verifying backendDuckDB(mode='client') skips preRenderHook - Add Shiny test apps for Arrow IPC and Parquet in client mode - Update server-side-data plan doc with completed selection and client mode work
1 parent 6e1a0ea commit c97f85f

File tree

7 files changed

+173
-36
lines changed

7 files changed

+173
-36
lines changed

R/reactable.R

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,10 @@ reactable <- function(
719719

720720
# Resolve backend mode: "auto" detects Shiny vs static, then route to client or server path
721721
isDuckDBClientMode <- FALSE
722+
isDuckDBClientModeExplicit <- FALSE
722723
isServerMode <- FALSE
723724
if (isDuckDBBackend(backend)) {
725+
isDuckDBClientModeExplicit <- identical(backend$mode, "client")
724726
resolvedMode <- resolveDuckDBMode(backend)
725727
if (resolvedMode == "server") {
726728
# Route DuckDB server mode through the server infrastructure
@@ -921,19 +923,22 @@ reactable <- function(
921923
# Warn if this widget ends up being rendered in a Shiny session, since DuckDB-WASM
922924
# (client mode) was selected because no Shiny session was detected at reactable() call time.
923925
# This happens when reactable() is called at the top level of a Shiny app script.
924-
preRenderHook <- function(instance) {
925-
session <- if (requireNamespace("shiny", quietly = TRUE)) {
926-
shiny::getDefaultReactiveDomain()
927-
}
928-
if (!is.null(session)) {
929-
warning(
930-
"`backendDuckDB()` was configured for client-side mode because `reactable()` was called ",
931-
"outside of a Shiny render function. Move the `reactable()` call inside `renderReactable()` ",
932-
"so the backend can detect the Shiny session and use server mode.",
933-
call. = FALSE
934-
)
926+
# Don't warn if the user explicitly set mode = "client".
927+
if (!isDuckDBClientModeExplicit) {
928+
preRenderHook <- function(instance) {
929+
session <- if (requireNamespace("shiny", quietly = TRUE)) {
930+
shiny::getDefaultReactiveDomain()
931+
}
932+
if (!is.null(session)) {
933+
warning(
934+
"`backendDuckDB()` was configured for client-side mode because `reactable()` was called ",
935+
"outside of a Shiny render function. Move the `reactable()` call inside `renderReactable()` ",
936+
"so the backend can detect the Shiny session and use server mode.",
937+
call. = FALSE
938+
)
939+
}
940+
instance
935941
}
936-
instance
937942
}
938943
} else {
939944
data <- toJSON(data)
@@ -1255,7 +1260,7 @@ duckdbDependency <- function() {
12551260
name = "duckdb-wasm",
12561261
version = "1.29.0",
12571262
src = system.file("htmlwidgets/lib/duckdb-wasm", package = "reactable"),
1258-
script = "reactable-duckdb.js",
1263+
script = c("duckdb-locator.js", "reactable-duckdb.js"),
12591264
all_files = TRUE
12601265
)
12611266
}
@@ -1264,9 +1269,10 @@ duckdbDependency <- function() {
12641269
# Includes a locator script that registers the Parquet file URL so JS can find it.
12651270
parquetDependency <- function(parquetDir, parquetFilename, parquetId) {
12661271
# Write a locator script that registers the Parquet URL for this widget.
1267-
# Uses document.currentScript to detect the deployed path (same technique as duckdb-entry.js).
1272+
# Uses document.currentScript to detect the deployed path, with a querySelector
1273+
# fallback for Shiny where scripts are dynamically inserted.
12681274
locatorJs <- sprintf(
1269-
"(function(){var s=document.currentScript;var b=s?s.src.replace(/[^/]*$/,''):'';window.__ReactableParquet=window.__ReactableParquet||{};window.__ReactableParquet['%s']=b+'%s';})();",
1275+
"(function(){var s=document.currentScript||document.querySelector('script[src*=\"parquet-locator\"]');var b=s?s.src.replace(/[^/]*$/,''):'';window.__ReactableParquet=window.__ReactableParquet||{};window.__ReactableParquet['%s']=b+'%s';})();",
12701276
parquetId, parquetFilename
12711277
)
12721278
locatorFile <- file.path(parquetDir, "parquet-locator.js")

design/duckdb-wasm-engine/duckdb-wasm-engine-test.Rmd

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,84 @@ reactable(
899899
)
900900
```
901901

902+
### Parquet in Shiny (client mode)
903+
904+
Test that Parquet sidecar files work in a Shiny app. In Shiny, `backendDuckDB()` defaults
905+
to server mode (data stays in R), so Parquet requires `mode = "client"` to force DuckDB-WASM.
906+
The Parquet file is served via Shiny's htmlDependency resource path, and the locator script
907+
resolves the URL using `document.currentScript.src`.
908+
909+
Test steps:
910+
1. Table should render and be interactive (sort, filter, search).
911+
2. Open browser DevTools Network tab -- requests to the `.parquet` file should use HTTP
912+
range requests (partial content, status 206 or 200 with Range header).
913+
3. Sorting, filtering should work without full file download.
914+
915+
```{r eval=FALSE}
916+
library(shiny)
917+
library(reactable)
918+
919+
n <- 1000000
920+
parquet_data <- data.frame(
921+
id = seq_len(n),
922+
value = rnorm(n),
923+
category = sample(LETTERS, n, replace = TRUE),
924+
score = round(runif(n, 0, 100), 2),
925+
label = paste0("item-", seq_len(n))
926+
)
927+
928+
ui <- fluidPage(
929+
h3("Parquet sidecar in Shiny (DuckDB client mode, 1M rows)"),
930+
reactableOutput("table")
931+
)
932+
933+
server <- function(input, output, session) {
934+
output$table <- renderReactable({
935+
reactable(
936+
parquet_data,
937+
backend = backendDuckDB(mode = "client", format = "parquet"),
938+
defaultPageSize = 20,
939+
sortable = TRUE,
940+
filterable = TRUE,
941+
searchable = TRUE,
942+
showPageSizeOptions = TRUE
943+
)
944+
})
945+
}
946+
947+
shinyApp(ui, server)
948+
```
949+
950+
### Arrow IPC in Shiny (client mode)
951+
952+
Test that embedded Arrow IPC works in a Shiny app with DuckDB client mode.
953+
This should work the same as the Parquet example but with data embedded in the HTML.
954+
955+
```{r eval=FALSE}
956+
library(shiny)
957+
library(reactable)
958+
959+
ui <- fluidPage(
960+
h3("Arrow IPC in Shiny (DuckDB client mode)"),
961+
reactableOutput("table")
962+
)
963+
964+
server <- function(input, output, session) {
965+
output$table <- renderReactable({
966+
reactable(
967+
MASS::Cars93,
968+
backend = backendDuckDB(mode = "client", format = "arrow"),
969+
defaultPageSize = 10,
970+
sortable = TRUE,
971+
filterable = TRUE,
972+
searchable = TRUE
973+
)
974+
})
975+
}
976+
977+
shinyApp(ui, server)
978+
```
979+
902980
## Cross-page selection
903981

904982
### Select-all across pages

design/server-side-data/server-side-data.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ Server-side data processing (`reactable(server = TRUE)`) moves sorting, filterin
1111
- ✅ Pagination, sorting, filtering, global search
1212
- ✅ Grouping with aggregation (V8 backend)
1313
- ✅ Custom S3 backend interface
14-
- ⚠️ Row selection/expansion only work for current page
14+
- ✅ Cross-page row selection (select-all, deselect-all, per-row)
15+
-`defaultSelected` with stable row IDs across pages
16+
-`updateReactable(selected = ...)` in backend modes
17+
- ✅ DuckDB-WASM client mode in Shiny (`mode = "client"`, both Arrow IPC and Parquet)
1518
- ⚠️ R render functions still run for entire table up front
1619
- ❌ No documentation vignette
1720

@@ -91,7 +94,6 @@ JavaScript sends these parameters on every data request:
9194
| `searchValue` | string | Global search value |
9295
| `groupBy` | list | Grouped column IDs |
9396
| `expanded` | object | Expanded row state (currently unused) |
94-
| `selectedRowIds` | object | Selected row state (currently unused) |
9597

9698
### Response Format
9799

@@ -164,25 +166,34 @@ df[["__state"]] <- listSafeDataFrame(
164166
When server-side search returns zero results, pagination shows "1-10 of 0 rows" instead of "0-0 of 0 rows".
165167

166168
#### 1.4 Stop Sending Unused State
167-
**File:** `srcjs/Reactable.js` lines 1180-1183
169+
**File:** `srcjs/Reactable.js`
170+
171+
~~Currently sends `expanded` and `selectedRowIds` in every request, but no backend uses them.~~ **Done.** `selectedRowIds` has been removed from server requests and all backend signatures. `expanded` is still sent for potential future use with `paginateSubRows`.
172+
173+
### 2. Server-Side Row Selection
168174

169-
Currently sends `expanded` and `selectedRowIds` in every request, but no backend uses them. Either:
170-
- Remove from requests until server-side selection/expansion is implemented
171-
- Or implement server-side versions (see section 2)
175+
~~This is complex work involving react-table hooks and row ID management.~~
172176

173-
### 2. Server-Side Row Selection and Expansion
177+
**Done.** Cross-page row selection is fully implemented:
174178

175-
This is complex work involving react-table hooks and row ID management.
179+
- Select-all queries the backend for all matching row IDs (via `selectAll` param) and stores them as explicit `selectedRowIds`
180+
- Deselect-all only removes the filtered/searched rows, leaving other selections intact
181+
- Per-row selections persist across page navigation, sort, and filter changes
182+
- `isAllRowsSelected` checks against `serverRowCount` (not just current page rows)
183+
- `toggleAllInProgressRef` guard prevents concurrent async calls from checkbox onChange+onClick bubbling
184+
- All 3 backends (DuckDB, V8, df) support `selectAll`
185+
- `defaultSelected` works correctly with `defaultSorted` via `__state` on pre-rendered pages
186+
- `updateReactable(selected = ...)` works in backend modes (0-based indices match `__state.id`)
176187

177-
#### Current Behavior
178-
- Selection and expansion fall back to client-side mode
179-
- Select-all and expand-all only affect rows on the current page
180-
- This matches ag-Grid's server-side model behavior
188+
### 3. DuckDB-WASM Client Mode in Shiny
181189

182-
#### Implementation Approach
190+
**Done.** DuckDB-WASM client mode (`backendDuckDB(mode = "client")`) works in Shiny apps:
183191

184-
**Option A: Document limitation (recommended for v1)**
185-
- Document that select-all/expand-all only work for current page
192+
- WASM base path detection: locator script (`duckdb-locator.js`) runs before the main bundle and uses `document.currentScript` with a `querySelector` fallback for Shiny (where scripts are dynamically inserted and `document.currentScript` is null)
193+
- Runtime fallback in `Reactable.js` `useEffect`: queries DOM for `script[src*="reactable-duckdb"]` to resolve WASM/worker paths
194+
- Parquet sidecar files: same `querySelector` fallback in the Parquet locator script and a runtime URL resolution fallback in `useEffect`
195+
- Both `format = "arrow"` and `format = "parquet"` work correctly
196+
- Warning suppressed when user explicitly sets `mode = "client"` (only warns for auto-resolved client mode)
186197
- This is acceptable behavior matching other table libraries
187198
- Much simpler than full server-side implementation
188199

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Detect and register the base path for DuckDB WASM files.
2+
// WASM and worker files are in the same directory as this script.
3+
// In Shiny, scripts are dynamically inserted via document.createElement(),
4+
// so document.currentScript is null. Fall back to querying the DOM.
5+
(function() {
6+
var s = document.currentScript ||
7+
document.querySelector('script[src*="duckdb-locator"]');
8+
var basePath = s ? s.src.replace(/[^/]*$/, '') : '';
9+
window.__ReactableDuckDBBasePath = basePath;
10+
})();

srcjs/Reactable.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1272,6 +1272,16 @@ function Table({
12721272
const parquetRegistry = window.__ReactableParquet
12731273
if (parquetRegistry && parquetRegistry[parquetId]) {
12741274
parquetUrl = parquetRegistry[parquetId]
1275+
// If the locator script couldn't detect its base path (e.g., in Shiny where
1276+
// document.currentScript is null for dynamically-inserted scripts), the URL
1277+
// will be just a filename. Resolve it against the locator script's actual URL
1278+
// from the DOM.
1279+
if (parquetUrl && !/^https?:\/\//.test(parquetUrl)) {
1280+
const locatorEl = document.querySelector('script[src*="parquet-locator"]')
1281+
if (locatorEl) {
1282+
parquetUrl = locatorEl.src.replace(/[^/]*$/, '') + parquetUrl
1283+
}
1284+
}
12751285
} else {
12761286
console.error(
12771287
'Parquet sidecar file not found for parquetId: ' +
@@ -1286,8 +1296,20 @@ function Table({
12861296
const duckdbBackend = new duckdbModule.DuckDBBackend()
12871297
duckdbRef.current = duckdbBackend
12881298

1299+
// Resolve wasmBasePath. The locator script sets this at load time via
1300+
// document.currentScript, but in Shiny, scripts are dynamically inserted
1301+
// and document.currentScript is null. By the time this useEffect runs,
1302+
// the script elements are in the DOM, so fall back to querySelector.
1303+
let { wasmBasePath } = duckdbModule
1304+
if (!wasmBasePath) {
1305+
const scriptEl = document.querySelector('script[src*="reactable-duckdb"]')
1306+
if (scriptEl) {
1307+
wasmBasePath = scriptEl.src.replace(/[^/]*$/, '')
1308+
}
1309+
}
1310+
12891311
duckdbBackend
1290-
.init({ arrowBase64: arrowData, parquetUrl, wasmBasePath: duckdbModule.wasmBasePath })
1312+
.init({ arrowBase64: arrowData, parquetUrl, wasmBasePath: wasmBasePath || '' })
12911313
.then(() => {
12921314
if (cancelled) return
12931315
setServerRowCount(duckdbBackend.totalRowCount)

srcjs/duckdb-entry.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { DuckDBBackend } from './DuckDBBackend'
22

3-
// Detect the base path for WASM files from this script's URL.
4-
// This script is loaded via an htmlDependency <script> tag, so
5-
// document.currentScript points to it. WASM files are in the same directory.
6-
const currentScript = document.currentScript
7-
const wasmBasePath = currentScript ? currentScript.src.replace(/[^/]*$/, '') : ''
3+
// Read the WASM base path from the locator script (duckdb-locator.js), which runs
4+
// as a separate <script> tag before this bundle. The locator uses
5+
// document.currentScript to detect its own URL, which is more reliable than trying
6+
// to detect the path from within a webpack bundle (where document.currentScript
7+
// may not be set, e.g., in Shiny's dynamic dependency loading).
8+
const wasmBasePath = window.__ReactableDuckDBBasePath || ''
89

910
// Register globally so the main reactable bundle can access it
1011
window.__ReactableDuckDB = {

tests/testthat/test-server.R

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,12 @@ test_that("backendDuckDB() client mode has preRenderHook for Shiny detection", {
3030
)
3131
expect_warning(tbl$preRenderHook(tbl), "outside of a Shiny render function")
3232
})
33+
34+
test_that("backendDuckDB(mode = 'client') skips preRenderHook warning", {
35+
skip_if_not_installed("arrow")
36+
37+
data <- data.frame(x = c(1, 2), y = c("a", "b"))
38+
tbl <- reactable(data, backend = backendDuckDB(mode = "client"))
39+
# preRenderHook should not be set when user explicitly chose client mode
40+
expect_null(tbl$preRenderHook)
41+
})

0 commit comments

Comments
 (0)