diff --git a/docs/md/how_to/javascript/importing.md b/docs/md/how_to/javascript/importing.md index 378bb9ec78..10a727e22d 100644 --- a/docs/md/how_to/javascript/importing.md +++ b/docs/md/how_to/javascript/importing.md @@ -38,13 +38,12 @@ The exact syntax will vary slightly depending on the bundler. ### Memory64 (wasm64) -`@perspective-dev/server` also ships a WebAssembly Memory64 build of the -engine, `dist/wasm/perspective-server.memory64.wasm`, which raises the -engine's heap ceiling from 4GB to 16GB (at some engine performance cost). -`init_server` accepts both binaries at once — register each as a _thunk_ and -only the selected binary is ever downloaded. The wasm64 binary is used -whenever the browser supports Memory64; registering only the wasm32 binary -(as above) opts out. +`@perspective-dev/server` also ships a WebAssembly Memory64 build of the engine, +`dist/wasm/perspective-server.memory64.wasm`, which raises the engine's heap +ceiling from 4GB to 16GB (at some engine performance cost). `init_server` +accepts both binaries at once — register each as a _thunk_ and only the selected +binary is ever downloaded. The wasm64 binary is used whenever the browser +supports Memory64; registering only the wasm32 binary (as above) opts out. ```javascript perspective.init_server({ @@ -138,18 +137,103 @@ Webpack config: ## Inline builds with a bundler -Inline builds are deprecated and will be removed in a -future release. +Perspective no longer publishes prebuilt _inline_ bundles. +The `@perspective-dev/client/inline` and `@perspective-dev/viewer/inline` entry +points, and the `dist/esm/*.inline.js` files behind them, have been removed. +Produce an equivalent build with your bundler's asset inlining, as below. -Perspective's _Inline_ Builds work by _inlining_ WebAssembly binary content as -a base64-encoded string. While inline builds work with most bundlers and _do -not_ require bootstrapping, there is an inherent file-size and boot-performance -penalty. Prefer your bundler's inlining features and Perspective ESM builds -where possible. +An _inline_ build embeds the WebAssembly binaries in the `.js` bundle as base64 +instead of emitting them as separate assets, so nothing needs to be served +alongside the bundle. It costs bundle size and boot time — base64 is 33% larger +than the binary it encodes, and the browser must decode it before compiling — so +prefer the ESM builds above where you control asset hosting. + +Only the bundler's `.wasm` loader changes; the bootstrapping code is identical +to the ESM builds. + +### ESBuild + +Use the `binary` loader in place of `file`. The imported value is a +`Uint8Array`, which `init_server()` and `init_client()` accept directly. + +```javascript +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; + +await Promise.all([ + perspective.init_server(SERVER_WASM), + perspective_viewer.init_client(CLIENT_WASM), +]); +``` + +ESBuild config JSON to encode this asset as `binary`: + +```javascript +{ + // ... + "loader": { + // ... + ".wasm": "binary" + } +} +``` + +### Webpack + +Use `asset/inline` in place of `asset/resource`. The imported value is a `data:` +URL rather than a path, so read it into an `ArrayBuffer` first. ```javascript -import "@perspective-dev/viewer/dist/esm/perspective-viewer.inline.js"; -import psp from "@perspective-dev/client/dist/esm/perspective.inline.js"; +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm"; + +const buffer = async (url) => await (await fetch(url)).arrayBuffer(); + +await Promise.all([ + perspective.init_server(await buffer(SERVER_WASM)), + perspective_viewer.init_client(await buffer(CLIENT_WASM)), +]); +``` + +Webpack config: + +```javascript +{ + // ... + module: { + // ... + rules: [ + // ... + { + test: /\.wasm$/, + type: "asset/inline" + }, + ] + }, + experiments: { + // ... + asyncWebAssembly: false, + syncWebAssembly: false, + }, +} +``` + +### Vite + +Vite inlines assets below `build.assetsInlineLimit` as `data:` URLs. Raising the +limit past the size of the `.wasm` binaries inlines them, and the `?url` +bootstrapping from the ESM section applies unchanged. This threshold is a +build-wide setting and its interaction with `?url` imports has changed between +Vite major versions, so check the emitted bundle rather than assuming. + +```javascript +import { defineConfig } from "vite"; +export default defineConfig({ + build: { + target: "esnext", + assetsInlineLimit: 16 * 1024 * 1024, + }, +}); ``` ## CDN builds diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts index f398b74b24..d07c850e84 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts @@ -20,7 +20,7 @@ import { } from "../../../webgl/instanced-attrs"; import { compileProgram } from "../../../webgl/program-cache"; import { colorRangePivot } from "../../../theme/gradient"; -import { formatTickValue, formatDateTickValue } from "../../../layout/ticks"; +import { buildPointRowTooltipLines } from "../tooltip-lines"; import lineVert from "../../../shaders/line.vert.glsl"; import lineFrag from "../../../shaders/line.frag.glsl"; @@ -122,41 +122,11 @@ export class LineGlyph implements Glyph { // helpers - async buildTooltipLines( + buildTooltipLines( chart: CartesianChart, flatIdx: number, ): Promise { - const grid: string[][] = []; - if (!chart._xData || !chart._yData) { - return grid; - } - - if (chart._splitGroups.length > 0 && chart._seriesCapacity > 0) { - const seriesIdx = Math.floor(flatIdx / chart._seriesCapacity); - const sg = chart._splitGroups[seriesIdx]; - if (sg) { - grid.push([sg.prefix]); - } - } - - const xVal = chart._xData[flatIdx]; - const yVal = chart._yData[flatIdx]; - - const xType = chart._columnTypes[chart._xLabel] || ""; - const xIsDate = xType === "date" || xType === "datetime"; - const xFormatted = xIsDate - ? formatDateTickValue(xVal) - : formatTickValue(xVal); - grid.push([chart._xLabel || "Row", xFormatted]); - - const yType = chart._columnTypes[chart._yLabel] || ""; - const yIsDate = yType === "date" || yType === "datetime"; - const yFormatted = yIsDate - ? formatDateTickValue(yVal) - : formatTickValue(yVal); - grid.push([chart._yLabel, yFormatted]); - - return grid; + return buildPointRowTooltipLines(chart, flatIdx); } tooltipOptions() { diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 4d3ca727ce..d9e384d915 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -18,7 +18,7 @@ import { sourceColumn, type NumberFormatConfig, type DateFormatConfig, -} from "@perspective-dev/viewer/src/ts/column-format.js"; +} from "@perspective-dev/viewer/column-format"; import type { ColumnDataMap } from "../data/view-reader"; import { LazyRowFetcher } from "../data/lazy-row"; import { diff --git a/packages/viewer-charts/src/ts/event-detail.ts b/packages/viewer-charts/src/ts/event-detail.ts index a29f660924..a0f7df51c0 100644 --- a/packages/viewer-charts/src/ts/event-detail.ts +++ b/packages/viewer-charts/src/ts/event-detail.ts @@ -49,4 +49,4 @@ export interface PerspectiveClickDetail { panel?: string; } -export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js"; +export { PerspectiveSelectDetail } from "@perspective-dev/viewer/select-detail"; diff --git a/packages/viewer-charts/src/ts/plugin/format-defaults.ts b/packages/viewer-charts/src/ts/plugin/format-defaults.ts index 145da5237b..50d0190006 100644 --- a/packages/viewer-charts/src/ts/plugin/format-defaults.ts +++ b/packages/viewer-charts/src/ts/plugin/format-defaults.ts @@ -13,7 +13,7 @@ import type { DateFormatConfig, NumberFormatConfig, -} from "@perspective-dev/viewer/src/ts/column-format.js"; +} from "@perspective-dev/viewer/column-format"; /** * The chart plugin's default per-column formats, declared to the viewer in diff --git a/packages/viewer-charts/test/ts/helpers.ts b/packages/viewer-charts/test/ts/helpers.ts index 756c8212c9..cb944120df 100644 --- a/packages/viewer-charts/test/ts/helpers.ts +++ b/packages/viewer-charts/test/ts/helpers.ts @@ -764,3 +764,91 @@ export function assertViewerQuiescent( } } } + +export async function readPinnedTooltip(page: Page): Promise { + return await page.evaluate(() => { + const host = document.querySelector( + 'perspective-viewer > [slot]:not([slot^="tab-"])', + ); + + const tip = host?.shadowRoot?.querySelector(".webgl-tooltip"); + return tip + ? [...tip.children].map((c) => (c.textContent ?? "").trim()) + : null; + }); +} + +export function tooltipValue( + cells: string[], + label: string, +): string | undefined { + const i = cells.indexOf(label); + return i >= 0 ? cells[i + 1] : undefined; +} + +export async function sweepPinnedTooltips( + page: Page, + step: { x: number; y: number } = { x: 0.02, y: 0.05 }, +): Promise { + const box = await page.evaluate(() => { + const host = document.querySelector( + 'perspective-viewer > [slot]:not([slot^="tab-"])', + ); + + if (!host) { + return null; + } + + const r = host.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + + if (!box) { + return []; + } + + const seen = new Map(); + for (let fx = 0.05; fx <= 0.96; fx += step.x) { + for (let fy = 0.05; fy <= 0.96; fy += step.y) { + await page.mouse.move(box.x + box.w * fx, box.y + box.h * fy); + await page.mouse.click(box.x + box.w * fx, box.y + box.h * fy); + const cells = await readPinnedTooltip(page); + if (cells && cells.length > 0) { + seen.set(JSON.stringify(cells), cells); + } + } + } + + return [...seen.values()]; +} + +export async function viewYearRange( + page: Page, + suffix: string, +): Promise<[number, number]> { + return await page.evaluate(async (s) => { + const viewer = document.querySelector("perspective-viewer")!; + const view = await (viewer as any).getView({ mode: "clone" }); + try { + const columns = await view.to_columns(); + const years: number[] = []; + for (const [name, values] of Object.entries(columns)) { + if (!name.endsWith(s)) { + continue; + } + + for (const value of values as (number | null)[]) { + if (value === null || value === undefined) { + continue; + } + + years.push(new Date(Number(value)).getFullYear()); + } + } + + return [Math.min(...years), Math.max(...years)] as [number, number]; + } finally { + await view.delete(); + } + }, suffix); +} diff --git a/rust/perspective-viewer/test/js/superstore/inline.spec.ts b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts similarity index 55% rename from rust/perspective-viewer/test/js/superstore/inline.spec.ts rename to packages/viewer-charts/test/ts/tooltip-source-values.spec.ts index 02e209f842..4712907028 100644 --- a/rust/perspective-viewer/test/js/superstore/inline.spec.ts +++ b/packages/viewer-charts/test/ts/tooltip-source-values.spec.ts @@ -10,38 +10,56 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { test } from "../helpers.ts"; -import { run_standard_tests } from "../helpers/standard_tests.ts"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { expect, test } from "@perspective-dev/test"; +import { + gotoBasic, + restoreChart, + sweepPinnedTooltips, + tooltipValue, + viewYearRange, +} from "./helpers"; -async function get_contents(page) { - return await page.evaluate(async () => { - const viewer = document.querySelector( - "perspective-viewer perspective-viewer-plugin", - ); +const DATETIME_X: ViewerConfigUpdate = { + columns: ["Order Date", "Sales"], + group_by: [], + split_by: ["Region"], + sort: [["Order Date", "asc"]], +} as ViewerConfigUpdate; - // Don't format - light DOM is CSV in a
 tag.
-        return viewer.innerHTML;
+const YEAR = /\b(\d{4})\b/;
+
+test.describe("Tooltip source values", () => {
+    test.beforeEach(async ({ page }) => {
+        await gotoBasic(page);
     });
-}
 
-test.describe("Superstore Inline", () => {
-    test.beforeEach(async function init({ page }) {
-        await page.goto(
-            "/node_modules/@perspective-dev/viewer/test/html/superstore-inline.html",
-        );
+    for (const plugin of ["X/Y Scatter", "X/Y Line"]) {
+        test(`${plugin} pins dates and values from the source row`, async ({
+            page,
+        }) => {
+            await restoreChart(page, {
+                ...DATETIME_X,
+                plugin,
+            } as ViewerConfigUpdate);
 
-        await page.evaluate(async () => {
-            while (!window["__TEST_PERSPECTIVE_READY__"]) {
-                await new Promise((x) => setTimeout(x, 10));
-            }
-        });
+            const [minYear, maxYear] = await viewYearRange(page, "Order Date");
+            expect(minYear).toBeGreaterThan(1970);
 
-        await page.evaluate(async () => {
-            await document.querySelector("perspective-viewer").restore({
-                plugin: "Debug",
-            });
-        });
-    });
+            const tooltips = await sweepPinnedTooltips(page);
+            expect(tooltips.length).toBeGreaterThan(4);
+
+            for (const cells of tooltips) {
+                const date = tooltipValue(cells, "Order Date");
+                const sales = tooltipValue(cells, "Sales");
+                expect(date).toBeDefined();
+                expect(sales).toBeDefined();
 
-    run_standard_tests("superstore inline", get_contents);
+                const year = Number(YEAR.exec(date!)?.[1]);
+                expect(year).toBeGreaterThanOrEqual(minYear - 1);
+                expect(year).toBeLessThanOrEqual(maxYear + 1);
+                expect(sales!.startsWith("-")).toBe(false);
+            }
+        });
+    }
 });
diff --git a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
index 677a662697..3e2fe499d1 100644
--- a/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
+++ b/packages/viewer-datagrid/src/ts/custom_elements/datagrid.ts
@@ -26,7 +26,7 @@ import column_config_schema, {
 import plugin_config_schema from "../plugin/plugin_config_schema.js";
 import datagridStyles from "../../../dist/css/perspective-viewer-datagrid.css";
 import { format_raw } from "../data_listener/format_cell.js";
-import { sourceColumn } from "@perspective-dev/viewer/src/ts/column-format.js";
+import { sourceColumn } from "@perspective-dev/viewer/column-format";
 
 import type { View, ViewWindow } from "@perspective-dev/client";
 import type {
diff --git a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
index 7983a44cb1..1633398dbe 100644
--- a/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
+++ b/packages/viewer-datagrid/src/ts/data_listener/formatter_cache.ts
@@ -15,7 +15,7 @@ import {
     createDateFormatter,
     createDatetimeFormatter,
     createNumberFormatter,
-} from "@perspective-dev/viewer/src/ts/column-format.js";
+} from "@perspective-dev/viewer/column-format";
 import type { ColumnConfig } from "../types.js";
 
 export interface Formatter {
diff --git a/packages/viewer-datagrid/src/ts/types.ts b/packages/viewer-datagrid/src/ts/types.ts
index deccd878d8..07a66fdb1f 100644
--- a/packages/viewer-datagrid/src/ts/types.ts
+++ b/packages/viewer-datagrid/src/ts/types.ts
@@ -592,7 +592,7 @@ export interface PerspectiveClickDetail {
     panel?: string;
 }
 
-export { PerspectiveSelectDetail } from "@perspective-dev/viewer/src/ts/extensions.js";
+export { PerspectiveSelectDetail } from "@perspective-dev/viewer/select-detail";
 
 // Mouse event with handled flag
 export interface HandledMouseEvent extends MouseEvent {
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
index bee333ba38..b068e35f33 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model.rs
@@ -417,7 +417,11 @@ impl GenericSQLVirtualServerModel {
 
         let all_columns: Vec = group_by_cols
             .into_iter()
-            .chain(data_columns.iter().map(|col| format!("\"{}\"", col)))
+            .chain(
+                data_columns
+                    .iter()
+                    .map(|col| format!("\"{}\"", quote_ident(col))),
+            )
             .collect();
 
         Ok(format!(
@@ -477,6 +481,7 @@ impl GenericSQLVirtualServerModel {
             ""
         };
 
+        let column_name = quote_ident(column_name);
         Ok(format!(
             "SELECT MIN(\"{}\"), MAX(\"{}\") FROM {}{}",
             column_name, column_name, view_id, where_clause
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
index c5702bbfcb..619a916bcf 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/table_make_view.rs
@@ -332,7 +332,7 @@ impl<'a> ViewQueryContext<'a> {
             expressions
                 .get(col)
                 .cloned()
-                .unwrap_or_else(|| format!("\"{}\"", col))
+                .unwrap_or_else(|| format!("\"{}\"", quote_ident(col)))
         };
 
         let row_id_expr = model.0.row_id_expr.as_deref().unwrap_or("rowid");
@@ -702,7 +702,7 @@ impl<'a> ViewQueryContext<'a> {
             .0
             .get(col)
             .cloned()
-            .unwrap_or_else(|| format!("\"{}\"", col))
+            .unwrap_or_else(|| format!("\"{}\"", quote_ident(col)))
     }
 
     fn get_aggregate(&self, col: &str) -> String {
@@ -991,7 +991,7 @@ impl<'a> ViewQueryContext<'a> {
         self.config
             .split_by
             .iter()
-            .map(|c| format!("\"{}\"", c))
+            .map(|c| format!("\"{}\"", quote_ident(c)))
             .collect::>()
             .join(", ")
     }
diff --git a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
index efcdc44ca8..4f27d88a51 100644
--- a/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
+++ b/rust/perspective-client/src/rust/virtual_server/generic_sql_model/tests.rs
@@ -1384,3 +1384,122 @@ fn test_expression_describe_uses_template() {
         "SELECT * FROM (SELECT \"x\" + 1 FROM t) AS __psp_describe__ LIMIT 0"
     );
 }
+
+#[test]
+fn test_view_get_data_escapes_double_quotes_in_column_paths() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let mut config = ViewConfig::default();
+    config.columns = vec![Some("amount".to_string())];
+    config.split_by = vec!["item_title".to_string()];
+    let viewport = ViewPort::default();
+
+    let mut schema = IndexMap::new();
+    schema.insert("plain|amount".to_string(), ColumnType::Integer);
+    schema.insert("say \"hi\"|amount".to_string(), ColumnType::Integer);
+    let sql = builder
+        .view_get_data("my_view", &config, &viewport, &schema)
+        .unwrap();
+
+    assert_eq!(
+        sql,
+        "SELECT \"plain|amount\", \"say \"\"hi\"\"|amount\" FROM my_view"
+    );
+}
+
+#[test]
+fn test_view_get_min_max_escapes_double_quotes_in_column_name() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let config = ViewConfig::default();
+
+    assert_eq!(
+        builder
+            .view_get_min_max("my_view", "say \"hi\"|amount", &config)
+            .unwrap(),
+        "SELECT MIN(\"say \"\"hi\"\"|amount\"), MAX(\"say \"\"hi\"\"|amount\") FROM my_view"
+    );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_column_names() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let mut config = ViewConfig::default();
+    config.columns = vec![Some("a\"b".to_string())];
+    let sql = builder
+        .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+        .unwrap();
+
+    assert!(
+        sql.contains("\"a\"\"b\" as \"a\"\"b\""),
+        "expected escaped column identifier: {}",
+        sql
+    );
+    assert!(
+        !sql.contains("\"a\"b\""),
+        "expected no unescaped column identifier: {}",
+        sql
+    );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_group_by() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let mut config = ViewConfig::default();
+    config.columns = vec![Some("value".to_string())];
+    config.group_by = vec!["ca\"t".to_string()];
+    config.group_rollup_mode = GroupRollupMode::Flat;
+    let sql = builder
+        .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+        .unwrap();
+
+    assert!(
+        sql.contains("GROUP BY \"ca\"\"t\""),
+        "expected escaped group_by identifier: {}",
+        sql
+    );
+}
+
+#[test]
+fn test_table_make_view_escapes_double_quotes_in_split_by() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let mut config = ViewConfig::default();
+    config.columns = vec![Some("value".to_string())];
+    config.group_by = vec!["category".to_string()];
+    config.split_by = vec!["sta\"te".to_string()];
+    let sql = builder
+        .table_make_view("source_table", "dest_view", &config, &IndexMap::new())
+        .unwrap();
+
+    assert!(
+        sql.contains("\"sta\"\"te\" || '|value'"),
+        "expected escaped split_by identifier in ON expression: {}",
+        sql
+    );
+    assert!(
+        !sql.contains("\"sta\"te\""),
+        "expected no unescaped split_by identifier: {}",
+        sql
+    );
+}
+
+#[test]
+fn test_view_get_data_orders_column_paths_containing_separator() {
+    let builder = GenericSQLVirtualServerModel::new(GenericSQLVirtualServerModelArgs::default());
+    let mut config = ViewConfig::default();
+    config.columns = vec![Some("amount".to_string()), Some("qty".to_string())];
+    config.split_by = vec!["item_title".to_string()];
+    let viewport = ViewPort::default();
+
+    let mut schema = IndexMap::new();
+    schema.insert("b|c|qty".to_string(), ColumnType::Integer);
+    schema.insert("a|b|amount".to_string(), ColumnType::Integer);
+    schema.insert("b|c|amount".to_string(), ColumnType::Integer);
+    schema.insert("a|b|qty".to_string(), ColumnType::Integer);
+    let sql = builder
+        .view_get_data("my_view", &config, &viewport, &schema)
+        .unwrap();
+
+    assert_eq!(
+        sql,
+        "SELECT \"a|b|amount\", \"a|b|qty\", \"b|c|amount\", \"b|c|qty\" FROM my_view"
+    );
+}
diff --git a/rust/perspective-js/build.mjs b/rust/perspective-js/build.mjs
index 256285d483..369a0da7cf 100644
--- a/rust/perspective-js/build.mjs
+++ b/rust/perspective-js/build.mjs
@@ -30,23 +30,6 @@ const BUILD = [
         // loader: { ".wasm": "binary" },
         outfile: "dist/cdn/perspective-server.worker.js",
     },
-    // WASM assets inlined into a single monolithic `.js` file. No special
-    // loades required, this version of Perspective should be the easiest
-    // to use but also the least performant at load time.
-    // {
-    //     'Import via `
-        
-        
-        
-    
-    
-        
-    
-
diff --git a/rust/perspective-viewer/test/js/windows.spec.ts b/rust/perspective-viewer/test/js/windows.spec.ts
index 7a13159832..2ced1a00c1 100644
--- a/rust/perspective-viewer/test/js/windows.spec.ts
+++ b/rust/perspective-viewer/test/js/windows.spec.ts
@@ -14,9 +14,7 @@ import { test, expect, compareInnerHTMLToSnapshot } from "./helpers.ts";
 
 test.describe("Window columns", () => {
     test.beforeEach(async function init({ page }) {
-        await page.goto(
-            "/rust/perspective-viewer/test/html/superstore-inline.html",
-        );
+        await page.goto("/rust/perspective-viewer/test/html/superstore.html");
 
         await page.evaluate(async () => {
             while (!window["__TEST_PERSPECTIVE_READY__"]) {
diff --git a/tools/bench/puppeteer_suite.mjs b/tools/bench/puppeteer_suite.mjs
index ab9e29e2ad..fe4e372c57 100644
--- a/tools/bench/puppeteer_suite.mjs
+++ b/tools/bench/puppeteer_suite.mjs
@@ -32,6 +32,27 @@ const VERSIONS = [
     "perspective-2-10-0",
 ];
 
+const WORKSPACE_CLIENT = "@perspective-dev/client";
+
+/**
+ * The module a candidate version loads from, plus the client wasm it must
+ * bootstrap itself with, since only published versions ship an inline build.
+ */
+function candidate_urls(version) {
+    if (version !== WORKSPACE_CLIENT) {
+        return {
+            module: `/tools/bench/node_modules/${version}/dist/esm/perspective.inline.js`,
+            client_wasm: null,
+        };
+    }
+
+    return {
+        module: "/node_modules/@perspective-dev/client/dist/cdn/perspective.js",
+        client_wasm:
+            "/node_modules/@perspective-dev/client/dist/wasm/perspective-js.wasm",
+    };
+}
+
 perspective_bench.suite(
     [...VERSIONS],
     path.join(__dirname, "dist/benchmark-js.arrow"),
@@ -48,10 +69,12 @@ perspective_bench.suite(
 
         async function test_suite(suite) {
             const items = await page.evaluate(
-                async ([version, suite, version_idx]) => {
-                    const { default: perspective } = await import(
-                        `/tools/bench/node_modules/${version}/dist/esm/perspective.inline.js`
-                    );
+                async ([version, suite, version_idx, urls]) => {
+                    const { default: perspective } = await import(urls.module);
+                    if (urls.client_wasm) {
+                        perspective.init_client(fetch(urls.client_wasm));
+                    }
+
                     const benchmarks = await import(
                         "/tools/bench/cross_platform_suite.mjs"
                     );
@@ -69,7 +92,7 @@ perspective_bench.suite(
                     await benchmarks[suite](client, metadata);
                     return total;
                 },
-                [path, suite, version_idx],
+                [path, suite, version_idx, candidate_urls(path)],
             );
 
             for (const { obs_records, stats } of items) {