Repository: deephaven/deephaven-plugins (language: python)

Issue:
Handle case where no data is provided and plot by is called
If there is no data provided and a plot by is called, an error is thrown: "No figures provided to compose"
should return an empty figure instead


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index c46ea4386..da4c17f11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,4 @@ tsconfig.tsbuildinfo
 .venv/
 
 # Ignore any local tox virtualenvs
-.tox/
\ No newline at end of file
+.tox/
diff --git a/package-lock.json b/package-lock.json
index f7b3a981e..aef722bfa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2588,6 +2588,8 @@
     },
     "node_modules/@deephaven/chart": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/chart/-/chart-0.53.0.tgz",
+      "integrity": "sha512-LJ9VcqiIF9Ozr9B+C59U1mxbZcuxWRMjrHwMQPUUA+zBKOH19F0Sxjh+B1v/eXhYP922hOjGB1dXgRGbD9r8Xw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/icons": "^0.53.0",
@@ -2613,6 +2615,8 @@
     },
     "node_modules/@deephaven/chart/node_modules/@deephaven/jsapi-types": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+      "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -2620,6 +2624,8 @@
     },
     "node_modules/@deephaven/chart/node_modules/@deephaven/jsapi-utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+      "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/filters": "^0.53.0",
@@ -2635,6 +2641,8 @@
     },
     "node_modules/@deephaven/chart/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -2645,6 +2653,8 @@
     },
     "node_modules/@deephaven/chart/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -2697,6 +2707,8 @@
     },
     "node_modules/@deephaven/console": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/console/-/console-0.53.0.tgz",
+      "integrity": "sha512-VSiTeLBsgaFhwF88eFb7YGwDJFz3xu1fqhQw3+ji6n21y/flmAChpAFB8uiOV9VWyVpTVmInmelR6TVkcxBK5g==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/chart": "^0.53.0",
@@ -2732,6 +2744,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -2767,6 +2781,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/jsapi-bootstrap": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+      "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -2783,6 +2799,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/jsapi-types": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+      "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -2790,6 +2808,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -2800,6 +2820,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -2817,6 +2839,8 @@
     },
     "node_modules/@deephaven/console/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -2850,6 +2874,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard-core-plugins/-/dashboard-core-plugins-0.53.0.tgz",
+      "integrity": "sha512-m9iO0qa/UJkY12K6MTnzvkhrwLeRLk/oXtIsHmTRhlS206PWu7IxV3g5106yvT2WbVV10opuSNL9UUJe7kWRzQ==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/chart": "^0.53.0",
@@ -2899,6 +2925,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -2934,6 +2962,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/dashboard": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-0.53.0.tgz",
+      "integrity": "sha512-G9P6DJZ+K3hna8YDnq2PRY6zYomdMtdovN/F03KbUeHhqtVEETATPNA99DpPJrVDYPw5ObR9wutk+oHLUtKtsg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -2961,6 +2991,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/golden-layout": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+      "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -2973,6 +3005,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/jsapi-bootstrap": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+      "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -2989,6 +3023,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/jsapi-components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-components/-/jsapi-components-0.53.0.tgz",
+      "integrity": "sha512-2w2KiKkBU8evv9mCSObtnJT7qNfoxn6JjZjYcNOjoqBQoTrFg2hV77KOvt/KtuXzmtl/L+R0DqAlJ52I0dOLYw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -3013,6 +3049,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/jsapi-types": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+      "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3020,6 +3058,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/jsapi-utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+      "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/filters": "^0.53.0",
@@ -3035,6 +3075,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -3045,6 +3087,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3062,6 +3106,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/redux": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-0.53.0.tgz",
+      "integrity": "sha512-tzkuF8LkmYzKIARITo7poPGqhLbvZ10RzSjiSUnzgg5DHmOpkTmek+Q2ZLPv25MIuu2vfKZacCdAP4BISe6Jlw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/jsapi-types": "^0.53.0",
@@ -3080,6 +3126,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3087,6 +3135,8 @@
     },
     "node_modules/@deephaven/dashboard-core-plugins/node_modules/react-is": {
       "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
       "license": "MIT",
       "peer": true
     },
@@ -3118,6 +3168,8 @@
     },
     "node_modules/@deephaven/file-explorer": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/file-explorer/-/file-explorer-0.53.0.tgz",
+      "integrity": "sha512-qbW+bVcLPzEargBjVVPhoyb4Tm/0T/kgS/J30WVyYV47PO1FohK6O4vxCiN4skzELm9MfIFJWMCKubKRgE+Gew==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -3140,6 +3192,8 @@
     },
     "node_modules/@deephaven/file-explorer/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3175,6 +3229,8 @@
     },
     "node_modules/@deephaven/file-explorer/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -3185,6 +3241,8 @@
     },
     "node_modules/@deephaven/file-explorer/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3202,6 +3260,8 @@
     },
     "node_modules/@deephaven/file-explorer/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3209,6 +3269,8 @@
     },
     "node_modules/@deephaven/filters": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/filters/-/filters-0.53.0.tgz",
+      "integrity": "sha512-t6lkBNXcdlkBUl+3IA0RAxN6K+SFEkEyqk+Cf0MxtJRVf48AMoOhtlvgrElvzSoZBWnmxP2nR4TWztJrmHHa8A==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3228,6 +3290,8 @@
     },
     "node_modules/@deephaven/grid": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/grid/-/grid-0.53.0.tgz",
+      "integrity": "sha512-8J0Tpak3EVT4+NQFbVTuhl2edOBSapH/VKXUO5fj4QfyQbW70bfosGFkLp6YfL87yDiA5+RTgAC9an7aBq0ymw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/utils": "^0.53.0",
@@ -3249,6 +3313,8 @@
     },
     "node_modules/@deephaven/grid/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3256,6 +3322,8 @@
     },
     "node_modules/@deephaven/icons": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/icons/-/icons-0.53.0.tgz",
+      "integrity": "sha512-bnoCLkpyGR3mbdi5vFcOmZ69KQD3SD5SGBCR/Pri0OGc1i8+HvLdcLKZFNdxtzCatEkwEyK2S2gpv+2IMbZn9A==",
       "license": "Apache-2.0",
       "dependencies": {
         "@fortawesome/fontawesome-common-types": "^6.1.1"
@@ -3267,6 +3335,8 @@
     },
     "node_modules/@deephaven/iris-grid": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/iris-grid/-/iris-grid-0.53.0.tgz",
+      "integrity": "sha512-aWpeqbmU0wznyZQka2ABNrmiShLUY1ub3Ft2E63gvq/0ecO+Xw01tidrKv6re86BDZt73Dr8P8aVWe0z9eV7wA==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -3307,6 +3377,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3342,6 +3414,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/jsapi-types": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+      "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3349,6 +3423,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/jsapi-utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+      "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/filters": "^0.53.0",
@@ -3364,6 +3440,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -3374,6 +3452,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3391,6 +3471,8 @@
     },
     "node_modules/@deephaven/iris-grid/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3507,6 +3589,8 @@
     },
     "node_modules/@deephaven/plugin": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/plugin/-/plugin-0.53.0.tgz",
+      "integrity": "sha512-p19Ke/Hi7IV5NxWapVbB/v/eiUN4N5tyDYI6i7kmP4LTq7vpNvQM72iaJ0bZEXar7Q2jso+jIo5h3FJP213c2w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -3527,6 +3611,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3562,6 +3648,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/golden-layout": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+      "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -3574,6 +3662,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/jsapi-types": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+      "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3581,6 +3671,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -3591,6 +3683,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -3608,6 +3702,8 @@
     },
     "node_modules/@deephaven/plugin/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -3662,6 +3758,8 @@
     },
     "node_modules/@deephaven/storage": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/storage/-/storage-0.53.0.tgz",
+      "integrity": "sha512-l4Fm1Brw2WgDhYcxqknXV8Tj2q2BYP1mL4qEjbKiGoosugaYH0hApVi11J/0KpjKlgECiOa5jPLPjRBtBQ49XA==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/filters": "^0.53.0",
@@ -3677,6 +3775,8 @@
     },
     "node_modules/@deephaven/storage/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -20530,6 +20630,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/components": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+      "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -20565,6 +20667,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/dashboard": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-0.53.0.tgz",
+      "integrity": "sha512-G9P6DJZ+K3hna8YDnq2PRY6zYomdMtdovN/F03KbUeHhqtVEETATPNA99DpPJrVDYPw5ObR9wutk+oHLUtKtsg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -20592,6 +20696,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/golden-layout": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+      "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -20604,6 +20710,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/jsapi-bootstrap": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+      "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/components": "^0.53.0",
@@ -20628,6 +20736,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/jsapi-utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+      "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/filters": "^0.53.0",
@@ -20643,6 +20753,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/log": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+      "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
       "license": "Apache-2.0",
       "dependencies": {
         "event-target-shim": "^6.0.2"
@@ -20653,6 +20765,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/react-hooks": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+      "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@adobe/react-spectrum": "^3.29.0",
@@ -20670,6 +20784,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/redux": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-0.53.0.tgz",
+      "integrity": "sha512-tzkuF8LkmYzKIARITo7poPGqhLbvZ10RzSjiSUnzgg5DHmOpkTmek+Q2ZLPv25MIuu2vfKZacCdAP4BISe6Jlw==",
       "license": "Apache-2.0",
       "dependencies": {
         "@deephaven/jsapi-types": "^0.53.0",
@@ -20688,6 +20804,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/@deephaven/utils": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+      "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA==",
       "license": "Apache-2.0",
       "engines": {
         "node": ">=16"
@@ -20695,6 +20813,8 @@
     },
     "plugins/plotly-express/src/js/node_modules/react-is": {
       "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
       "license": "MIT",
       "peer": true
     },
@@ -22884,6 +23004,8 @@
     },
     "@deephaven/chart": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/chart/-/chart-0.53.0.tgz",
+      "integrity": "sha512-LJ9VcqiIF9Ozr9B+C59U1mxbZcuxWRMjrHwMQPUUA+zBKOH19F0Sxjh+B1v/eXhYP922hOjGB1dXgRGbD9r8Xw==",
       "requires": {
         "@deephaven/icons": "^0.53.0",
         "@deephaven/jsapi-types": "^0.53.0",
@@ -22901,10 +23023,14 @@
       },
       "dependencies": {
         "@deephaven/jsapi-types": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+          "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg=="
         },
         "@deephaven/jsapi-utils": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+          "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
           "requires": {
             "@deephaven/filters": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -22916,12 +23042,16 @@
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
@@ -22961,6 +23091,8 @@
     },
     "@deephaven/console": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/console/-/console-0.53.0.tgz",
+      "integrity": "sha512-VSiTeLBsgaFhwF88eFb7YGwDJFz3xu1fqhQw3+ji6n21y/flmAChpAFB8uiOV9VWyVpTVmInmelR6TVkcxBK5g==",
       "requires": {
         "@deephaven/chart": "^0.53.0",
         "@deephaven/components": "^0.53.0",
@@ -22988,6 +23120,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -23015,6 +23149,8 @@
         },
         "@deephaven/jsapi-bootstrap": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+          "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23023,16 +23159,22 @@
           }
         },
         "@deephaven/jsapi-types": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+          "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg=="
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -23042,7 +23184,9 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
@@ -23065,6 +23209,8 @@
     },
     "@deephaven/dashboard-core-plugins": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/dashboard-core-plugins/-/dashboard-core-plugins-0.53.0.tgz",
+      "integrity": "sha512-m9iO0qa/UJkY12K6MTnzvkhrwLeRLk/oXtIsHmTRhlS206PWu7IxV3g5106yvT2WbVV10opuSNL9UUJe7kWRzQ==",
       "requires": {
         "@deephaven/chart": "^0.53.0",
         "@deephaven/components": "^0.53.0",
@@ -23105,6 +23251,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -23132,6 +23280,8 @@
         },
         "@deephaven/dashboard": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-0.53.0.tgz",
+          "integrity": "sha512-G9P6DJZ+K3hna8YDnq2PRY6zYomdMtdovN/F03KbUeHhqtVEETATPNA99DpPJrVDYPw5ObR9wutk+oHLUtKtsg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/golden-layout": "^0.53.0",
@@ -23149,6 +23299,8 @@
         },
         "@deephaven/golden-layout": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+          "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "jquery": "^3.6.0"
@@ -23156,6 +23308,8 @@
         },
         "@deephaven/jsapi-bootstrap": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+          "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23165,6 +23319,8 @@
         },
         "@deephaven/jsapi-components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-components/-/jsapi-components-0.53.0.tgz",
+          "integrity": "sha512-2w2KiKkBU8evv9mCSObtnJT7qNfoxn6JjZjYcNOjoqBQoTrFg2hV77KOvt/KtuXzmtl/L+R0DqAlJ52I0dOLYw==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/jsapi-bootstrap": "^0.53.0",
@@ -23181,10 +23337,14 @@
           }
         },
         "@deephaven/jsapi-types": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+          "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg=="
         },
         "@deephaven/jsapi-utils": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+          "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
           "requires": {
             "@deephaven/filters": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23196,12 +23356,16 @@
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -23212,6 +23376,8 @@
         },
         "@deephaven/redux": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-0.53.0.tgz",
+          "integrity": "sha512-tzkuF8LkmYzKIARITo7poPGqhLbvZ10RzSjiSUnzgg5DHmOpkTmek+Q2ZLPv25MIuu2vfKZacCdAP4BISe6Jlw==",
           "requires": {
             "@deephaven/jsapi-types": "^0.53.0",
             "@deephaven/jsapi-utils": "^0.53.0",
@@ -23222,10 +23388,14 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         },
         "react-is": {
           "version": "17.0.2",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+          "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
           "peer": true
         },
         "redux-thunk": {
@@ -23245,6 +23415,8 @@
     },
     "@deephaven/file-explorer": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/file-explorer/-/file-explorer-0.53.0.tgz",
+      "integrity": "sha512-qbW+bVcLPzEargBjVVPhoyb4Tm/0T/kgS/J30WVyYV47PO1FohK6O4vxCiN4skzELm9MfIFJWMCKubKRgE+Gew==",
       "requires": {
         "@deephaven/components": "^0.53.0",
         "@deephaven/icons": "^0.53.0",
@@ -23260,6 +23432,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -23287,12 +23461,16 @@
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -23302,12 +23480,16 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
     "@deephaven/filters": {
-      "version": "0.53.0"
+      "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/filters/-/filters-0.53.0.tgz",
+      "integrity": "sha512-t6lkBNXcdlkBUl+3IA0RAxN6K+SFEkEyqk+Cf0MxtJRVf48AMoOhtlvgrElvzSoZBWnmxP2nR4TWztJrmHHa8A=="
     },
     "@deephaven/golden-layout": {
       "version": "0.40.4",
@@ -23318,6 +23500,8 @@
     },
     "@deephaven/grid": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/grid/-/grid-0.53.0.tgz",
+      "integrity": "sha512-8J0Tpak3EVT4+NQFbVTuhl2edOBSapH/VKXUO5fj4QfyQbW70bfosGFkLp6YfL87yDiA5+RTgAC9an7aBq0ymw==",
       "requires": {
         "@deephaven/utils": "^0.53.0",
         "classnames": "^2.3.1",
@@ -23331,18 +23515,24 @@
       },
       "dependencies": {
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
     "@deephaven/icons": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/icons/-/icons-0.53.0.tgz",
+      "integrity": "sha512-bnoCLkpyGR3mbdi5vFcOmZ69KQD3SD5SGBCR/Pri0OGc1i8+HvLdcLKZFNdxtzCatEkwEyK2S2gpv+2IMbZn9A==",
       "requires": {
         "@fortawesome/fontawesome-common-types": "^6.1.1"
       }
     },
     "@deephaven/iris-grid": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/iris-grid/-/iris-grid-0.53.0.tgz",
+      "integrity": "sha512-aWpeqbmU0wznyZQka2ABNrmiShLUY1ub3Ft2E63gvq/0ecO+Xw01tidrKv6re86BDZt73Dr8P8aVWe0z9eV7wA==",
       "requires": {
         "@deephaven/components": "^0.53.0",
         "@deephaven/console": "^0.53.0",
@@ -23375,6 +23565,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -23401,10 +23593,14 @@
           }
         },
         "@deephaven/jsapi-types": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+          "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg=="
         },
         "@deephaven/jsapi-utils": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+          "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
           "requires": {
             "@deephaven/filters": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23416,12 +23612,16 @@
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -23431,7 +23631,9 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
@@ -23562,6 +23764,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -23589,6 +23793,8 @@
         },
         "@deephaven/dashboard": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/dashboard/-/dashboard-0.53.0.tgz",
+          "integrity": "sha512-G9P6DJZ+K3hna8YDnq2PRY6zYomdMtdovN/F03KbUeHhqtVEETATPNA99DpPJrVDYPw5ObR9wutk+oHLUtKtsg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/golden-layout": "^0.53.0",
@@ -23606,6 +23812,8 @@
         },
         "@deephaven/golden-layout": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+          "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "jquery": "^3.6.0"
@@ -23613,6 +23821,8 @@
         },
         "@deephaven/jsapi-bootstrap": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-bootstrap/-/jsapi-bootstrap-0.53.0.tgz",
+          "integrity": "sha512-1BTpp+vVeVngUARVCmWsS9DSrtbt0ahOGVdXuqz6mcZDSzzJyQzqzozl+aQdqfnDxu4uA6sLQDcEP3XjGpiIeg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23627,6 +23837,8 @@
         },
         "@deephaven/jsapi-utils": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-utils/-/jsapi-utils-0.53.0.tgz",
+          "integrity": "sha512-7OgNi9DHtkbAqWs0jkXBd+o5qLI5ySBZW2RJM8kpUvE3mREFn3V0J2iM+wT6KXbTR2ixX4vXC8OeXm5uJE3w+w==",
           "requires": {
             "@deephaven/filters": "^0.53.0",
             "@deephaven/jsapi-types": "^0.53.0",
@@ -23638,12 +23850,16 @@
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -23654,6 +23870,8 @@
         },
         "@deephaven/redux": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/redux/-/redux-0.53.0.tgz",
+          "integrity": "sha512-tzkuF8LkmYzKIARITo7poPGqhLbvZ10RzSjiSUnzgg5DHmOpkTmek+Q2ZLPv25MIuu2vfKZacCdAP4BISe6Jlw==",
           "requires": {
             "@deephaven/jsapi-types": "^0.53.0",
             "@deephaven/jsapi-utils": "^0.53.0",
@@ -23664,10 +23882,14 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         },
         "react-is": {
           "version": "17.0.2",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+          "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
           "peer": true
         },
         "redux-thunk": {
@@ -24105,6 +24327,8 @@
     },
     "@deephaven/plugin": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/plugin/-/plugin-0.53.0.tgz",
+      "integrity": "sha512-p19Ke/Hi7IV5NxWapVbB/v/eiUN4N5tyDYI6i7kmP4LTq7vpNvQM72iaJ0bZEXar7Q2jso+jIo5h3FJP213c2w==",
       "requires": {
         "@deephaven/components": "^0.53.0",
         "@deephaven/golden-layout": "^0.53.0",
@@ -24118,6 +24342,8 @@
       "dependencies": {
         "@deephaven/components": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/components/-/components-0.53.0.tgz",
+          "integrity": "sha512-fIqWKS714V4Ee7JxeOvmuSLIAEKue3YrKfaBmE91NUVw301Zyp4/xY2VhR65LrfgDp8I7B/0ygS/IaEn40nyow==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/icons": "^0.53.0",
@@ -24145,22 +24371,30 @@
         },
         "@deephaven/golden-layout": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/golden-layout/-/golden-layout-0.53.0.tgz",
+          "integrity": "sha512-HU/NZLxr68WcuMsfh8QEO45DTpQ5hcvU7PCAxkKd+6HZr0MW9vigpEszrAntkIT/oxeV+SsitiyPT5DmYSCKhg==",
           "requires": {
             "@deephaven/components": "^0.53.0",
             "jquery": "^3.6.0"
           }
         },
         "@deephaven/jsapi-types": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-0.53.0.tgz",
+          "integrity": "sha512-S1dM5AuatOeKFkE/Uk6Vya9f+1cgnAi8wZ256X481tdpR4hDbVjFvr5BHUg6HFJO+R2G+dzhjFdhXR1mKEdIcg=="
         },
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
         },
         "@deephaven/react-hooks": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/react-hooks/-/react-hooks-0.53.0.tgz",
+          "integrity": "sha512-SVGgvs36tJNUUwvbQAUUpfHCZ0KWA25HTB80FfVrX++l1Eixo3SC3RjlzWJa1CPz4tOcp2ExEVngitEP3qhHbw==",
           "requires": {
             "@adobe/react-spectrum": "^3.29.0",
             "@deephaven/log": "^0.53.0",
@@ -24170,7 +24404,9 @@
           }
         },
         "@deephaven/utils": {
-          "version": "0.53.0"
+          "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-0.53.0.tgz",
+          "integrity": "sha512-UQ/bLtiGuCpwvA4PTTAR3cPemAfq5eJ2R7KCqc2BAb5s1ctSrLwpaS/xpdCzoAa3R2mtUK2sfRSxDb1ACmRgSA=="
         }
       }
     },
@@ -24205,6 +24441,8 @@
     },
     "@deephaven/storage": {
       "version": "0.53.0",
+      "resolved": "https://registry.npmjs.org/@deephaven/storage/-/storage-0.53.0.tgz",
+      "integrity": "sha512-l4Fm1Brw2WgDhYcxqknXV8Tj2q2BYP1mL4qEjbKiGoosugaYH0hApVi11J/0KpjKlgECiOa5jPLPjRBtBQ49XA==",
       "requires": {
         "@deephaven/filters": "^0.53.0",
         "@deephaven/log": "^0.53.0",
@@ -24213,6 +24451,8 @@
       "dependencies": {
         "@deephaven/log": {
           "version": "0.53.0",
+          "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-0.53.0.tgz",
+          "integrity": "sha512-ssDnmmFzXB0oZv7yywu/d+wO4Rryo6sDR87AqWPmdBiZrHiyTDEWriovz9HE2ZrQyyBhO2LPOIlgyWXg5QU0og==",
           "requires": {
             "event-target-shim": "^6.0.2"
           }
diff --git a/plugins/plotly-express/src/deephaven/plot/express/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/__init__.py
index df63ba966..89d33f51f 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/__init__.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/__init__.py
@@ -1,7 +1,12 @@
+from __future__ import annotations
+
+import json
+
 from deephaven.plugin import Registration, Callback
-from deephaven.plugin.object_type import Exporter, FetchOnlyObjectType
+from deephaven.plugin.object_type import BidirectionalObjectType, MessageStream
 
-from .deephaven_figure import DeephavenFigure, export_figure
+from .communication.DeephavenFigureConnection import DeephavenFigureConnection
+from .deephaven_figure import DeephavenFigure
 
 from .plots import (
     area,
@@ -44,7 +49,7 @@
 NAME = "deephaven.plot.express.DeephavenFigure"
 
 
-class DeephavenFigureType(FetchOnlyObjectType):
+class DeephavenFigureType(BidirectionalObjectType):
     """
     DeephavenFigureType for plugin registration
 
@@ -73,18 +78,29 @@ def is_type(self, obj: any) -> bool:
         """
         return isinstance(obj, DeephavenFigure)
 
-    def to_bytes(self, exporter: Exporter, figure: DeephavenFigure) -> bytes:
+    def create_client_connection(
+        self, obj: DeephavenFigure, connection: MessageStream
+    ) -> MessageStream:
         """
-        Converts a DeephavenFigure to bytes
+        Create a client connection for the DeephavenFigure.
+        This sends an initial figure to the client.
 
         Args:
-          exporter: Exporter: The exporter to use
-          figure: DeephavenFigure: The figure to convert
+          obj: object: The object to create the connection for
+          connection: MessageStream: The connection to use
 
         Returns:
-            bytes: The Figure as bytes
+            MessageStream: The client connection
         """
-        return export_figure(exporter, figure)
+        figure_connection = DeephavenFigureConnection(obj, connection)
+        initial_message = json.dumps(
+            {
+                "type": "RETRIEVE",
+            }
+        ).encode()
+        payload, references = figure_connection.on_data(initial_message, [])
+        connection.on_data(payload, references)
+        return figure_connection
 
 
 class ChartRegistration(Registration):
diff --git a/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureConnection.py b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureConnection.py
new file mode 100644
index 000000000..ccf896cbb
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureConnection.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from typing import Any
+
+from deephaven.plugin.object_type import MessageStream
+
+from ..deephaven_figure import DeephavenFigure
+from .DeephavenFigureListener import DeephavenFigureListener
+
+
+class DeephavenFigureConnection(MessageStream):
+    """
+    Connection for DeephavenFigure
+
+    Attributes:
+        _listener: DeephavenFigureListener: The listener for the figure
+        _client_connection: MessageStream: The connection to the client
+    """
+
+    def __init__(self, figure: DeephavenFigure, client_connection: MessageStream):
+        super().__init__()
+
+        self._listener = DeephavenFigureListener(
+            figure,
+            client_connection,
+        )
+        self._client_connection = client_connection
+
+    def on_data(self, payload: bytes, references: list[Any]) -> tuple[bytes, list[Any]]:
+        """
+        Args:
+            payload: Payload to execute
+            references: References to objects on the server
+
+        Returns:
+            tuple[bytes, list[Any]]: The payload to send to the client and the
+                references to send to the client
+        """
+        return self._listener.process_message(payload, references)
+
+    def on_close(self) -> None:
+        """
+        Close the connection
+        """
+        pass
diff --git a/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py
new file mode 100644
index 000000000..82fdb8c6f
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/communication/DeephavenFigureListener.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+import json
+from functools import partial
+from typing import Any
+
+from deephaven.plugin.object_type import MessageStream
+from deephaven.table_listener import listen, TableUpdate
+from deephaven.liveness_scope import LivenessScope
+
+from ..exporter import Exporter
+from ..deephaven_figure import DeephavenFigure, DeephavenFigureNode, RevisionManager
+
+
+class DeephavenFigureListener:
+    """
+    Listener for DeephavenFigure
+
+    Attributes:
+        _connection: MessageStream: The connection to send messages to
+        _figure: DeephavenFigure: The figure to listen to
+        _exporter: Exporter: The exporter to use for exporting the figure
+        _liveness_scope: Any: The liveness scope to use for the listeners
+        _listeners: list[Any]: The listeners for the partitioned tables
+        _partitioned_tables: dict[str, tuple[PartitionedTable, DeephavenFigureNode]]:
+            The partitioned tables to listen to
+        _revision_manager: RevisionManager: The revision manager to use for the figure
+        _handles: list[Any]: The handles for the listeners
+    """
+
+    def __init__(
+        self,
+        figure: DeephavenFigure,
+        connection: MessageStream,
+    ):
+        """
+        Create a new listener for the figure
+
+        Args:
+            figure: DeephavenFigure: The figure to listen to
+            connection: MessageStream: The connection to send messages to
+        """
+        self._connection = connection
+
+        # copy the figure so only this session's figure is updated
+        # the liveness scope is needed to keep any tables alive
+        self._figure = figure.copy()
+        self._exporter = Exporter()
+        self._liveness_scope = LivenessScope()
+        # store hard references to the handles so they don't get garbage collected
+        self._handles = []
+        self._listeners = []
+        self._revision_manager = RevisionManager()
+
+        head_node = self._figure.get_head_node()
+        self._partitioned_tables = head_node.partitioned_tables
+
+        self._setup_listeners()
+
+        # force figure to be recreated after listeners are setup
+        # this ensures the figures are created correctly
+        # such as when partitions have been added but no listeners have been running
+        self._figure.recreate_figure()
+
+    def _setup_listeners(self) -> None:
+        """
+        Setup listeners for the partitioned tables
+        """
+        for table, node in self._partitioned_tables.values():
+            listen_func = partial(self._on_update, node)
+            handle = listen(table, listen_func)
+            self._handles.append(handle)
+            self._liveness_scope.manage(handle)
+
+    def _get_figure(self) -> DeephavenFigure:
+        """
+        Get the current figure
+
+        Returns:
+            The current figure
+        """
+        return self._figure.get_figure()
+
+    def _on_update(
+        self, node: DeephavenFigureNode, update: TableUpdate, is_replay: bool
+    ) -> None:
+        """
+        Update the figure. Because this is called when the PartitionedTable
+        meta table is updated, it will always trigger a rerender.
+
+        Args:
+            node: DeephavenFigureNode: The node to update. Changes will propagate up from this node.
+            update: TableUpdate: Not used. Required for the listener.
+            is_replay: bool: Not used. Required for the listener.
+        """
+        if self._connection:
+            revision = self._revision_manager.get_revision()
+            node.recreate_figure()
+            try:
+                self._connection.on_data(
+                    *self._build_figure_message(self._get_figure(), revision)
+                )
+            except RuntimeError:
+                # trying to send data when the connection is closed, ignore
+                pass
+
+    def _handle_retrieve_figure(self) -> tuple[bytes, list[Any]]:
+        """
+        Handle a retrieve message. This will return a message with the current
+        figure.
+
+        Returns:
+            tuple[bytes, list[Any]]: The result of the message as a tuple of
+              (new payload, new references)
+        """
+        return self._build_figure_message(self._get_figure())
+
+    def _build_figure_message(
+        self, figure: DeephavenFigure, revision: int = None
+    ) -> tuple[bytes, list[Any]]:
+        """
+        Build a message to send to the client with the current figure.
+
+        Args:
+            figure: DeephavenFigure: The figure to send
+
+        Returns:
+            tuple[bytes, list[Any]]: The result of the message as a tuple of
+              (new payload, new references)
+        """
+        exporter = self._exporter
+
+        with self._revision_manager:
+            # if revision is None, just send the figure
+            if revision is not None:
+                self._revision_manager.updated_revision(revision)
+
+            new_figure = figure.to_dict(exporter=exporter)
+
+            new_objects, new_references, removed_references = exporter.references()
+
+            message = {
+                "type": "NEW_FIGURE",
+                "figure": new_figure,
+                "revision": self._revision_manager.current_revision,
+                "new_references": new_references,
+                "removed_references": removed_references,
+            }
+            return json.dumps(message).encode(), new_objects
+            # otherwise, don't need to send anything, as a newer revision has
+            # already been sent
+
+    def process_message(
+        self, payload: bytes, references: list[Any]
+    ) -> tuple[bytes, list[Any]]:
+        """
+        The main message processing function. This will handle the message
+        and return the result.
+
+        Args:
+            payload: bytes: The payload to process
+            references:  list[Any]: References to objects on the server
+
+        Returns:
+            tuple[bytes, list[Any]]: The result of the message as a tuple of
+              (new payload, new references)
+
+        """
+        # need to create a new exporter for each message
+        message = json.loads(payload.decode())
+        if message["type"] == "RETRIEVE":
+            return self._handle_retrieve_figure()
+
+    def __del__(self):
+        self._liveness_scope.release()
diff --git a/plugins/plotly-express/src/deephaven/plot/express/communication/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/communication/__init__.py
new file mode 100644
index 000000000..01596a2e1
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/communication/__init__.py
@@ -0,0 +1,2 @@
+from .DeephavenFigureListener import DeephavenFigureListener
+from .DeephavenFigureConnection import DeephavenFigureConnection
diff --git a/plugins/plotly-express/src/deephaven/plot/express/data/data_generators.py b/plugins/plotly-express/src/deephaven/plot/express/data/data_generators.py
index 8ef6fe5d5..eaf102c99 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/data/data_generators.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/data/data_generators.py
@@ -11,7 +11,7 @@
 from deephaven.updateby import rolling_sum_tick, ema_tick
 import random, math
 
-SECOND = 1000000000  #: One second in nanoseconds.
+SECOND = 1_000_000_000  #: One second in nanoseconds.
 MINUTE = 60 * SECOND  #: One minute in nanoseconds.
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/data_mapping/DataMapping.py b/plugins/plotly-express/src/deephaven/plot/express/data_mapping/DataMapping.py
index d549cf66a..dcc553cbe 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/data_mapping/DataMapping.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/data_mapping/DataMapping.py
@@ -42,7 +42,7 @@ def get_links(self: DataMapping, exporter: Exporter) -> list[dict[Any]]:
 
         """
         return json_link_mapping(
-            self._data_mapping, exporter.reference(self._table).index, self._start_index
+            self._data_mapping, exporter.reference(self._table).id, self._start_index
         )
 
     def copy(self: DataMapping, offset: int) -> DataMapping:
diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/DeephavenFigure.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/DeephavenFigure.py
index fc0fd7e94..62876739d 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/DeephavenFigure.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/DeephavenFigure.py
@@ -3,26 +3,18 @@
 import json
 from collections.abc import Generator
 from typing import Callable, Any
-
 from plotly.graph_objects import Figure
+from abc import abstractmethod
+from copy import copy
 
-from deephaven.plugin.object_type import Exporter
+from deephaven.table import PartitionedTable, Table
+from deephaven.execution_context import ExecutionContext
+from deephaven.liveness_scope import LivenessScope
 
+from ..shared import args_copy
 from ..data_mapping import DataMapping
-
-
-def export_figure(exporter: Exporter, figure: DeephavenFigure) -> bytes:
-    """Helper to export a DeephavenFigure as json
-
-    Args:
-      exporter: Exporter: The exporter to use
-      figure: DeephavenFigure: The figure to export
-
-    Returns:
-      bytes: The figure as bytes
-
-    """
-    return figure.to_json(exporter).encode()
+from ..exporter import Exporter
+from .RevisionManager import RevisionManager
 
 
 def has_color_args(call_args: dict[str, Any]) -> bool:
@@ -63,30 +55,333 @@ def has_arg(call_args: dict[str, Any], check: str | Callable) -> bool:
     # check is either a function or string
 
 
+class DeephavenNode:
+    """
+    A node in the DeephavenFigure graph. This is an abstract class that
+    represents a node in the graph.
+    """
+
+    @abstractmethod
+    def recreate_figure(self) -> None:
+        """
+        Recreate the figure. This is called when an underlying partition or
+        child figure changes.
+        """
+        pass
+
+    @abstractmethod
+    def copy(
+        self,
+        parent: DeephavenNode | DeephavenHeadNode,
+        partitioned_tables: dict[int, tuple[PartitionedTable, DeephavenNode]],
+    ) -> DeephavenNode:
+        """
+        Copy this node and all children nodes
+
+        Args:
+            parent: DeephavenNode | DeephavenHeadNode: The parent node
+            partitioned_tables:
+                dict[int, tuple[PartitionedTable, DeephavenNode]]:
+                A dictionary mapping node ids to partitioned tables and nodes
+            liveness_scope: LivenessScope: The liveness scope to use for the
+                copied nodes
+
+        Returns:
+            DeephavenNode: The new node
+        """
+        pass
+
+    @abstractmethod
+    def get_figure(self) -> DeephavenFigure:
+        """
+        Get the figure for this node. It will be generated if not cached
+
+        Returns:
+            DeephavenFigure: The figure for this node
+        """
+        pass
+
+
+class DeephavenFigureNode(DeephavenNode):
+    """
+    A node in the DeephavenFigure graph that represents a figure.
+
+    Attributes:
+        parent: DeephavenNode | DeephavenHeadNode: The parent node
+        exec_ctx: ExecutionContext: The execution context
+        args: dict[str, Any]: The arguments to the function
+        table: PartitionedTable: The table to pull data from
+        func: Callable: The function to call
+        cached_figure: DeephavenFigure: The cached figure
+        revision_manager: RevisionManager: The revision manager to use for the figure node
+    """
+
+    def __init__(
+        self,
+        parent: DeephavenNode | DeephavenHeadNode = None,
+        exec_ctx: ExecutionContext = None,
+        args: dict[str, Any] = None,
+        table: Table | PartitionedTable = None,
+        func: Callable = None,
+    ):
+        """
+        Create a new DeephavenFigureNode
+
+        Args:
+            parent: DeephavenNode | DeephavenHeadNode: The parent node
+            exec_ctx: ExecutionContext: The execution context
+            args: dict[str, Any]: The arguments to the function
+            table: PartitionedTable: The table to pull data from
+            func: Callable: The function to call
+        """
+        self.parent = parent
+        self.exec_ctx = exec_ctx
+        self.args = args
+        self.table = table
+        self.func = func
+        self.cached_figure = None
+        self.revision_manager = RevisionManager()
+
+    def recreate_figure(self, update_parent: bool = True) -> None:
+        """
+        Recreate the figure. This is called when the underlying partition
+        changes
+
+        Args:
+            update_parent: bool: (Default value = True): If the parent should
+            be updated
+        """
+        revision = self.revision_manager.get_revision()
+
+        # release the lock to ensure there is no deadlock
+        # as for some table operations an exclusive lock is required
+        with self.exec_ctx:
+            table = self.table
+            copied_args = args_copy(self.args)
+            copied_args["args"]["table"] = table
+            new_figure = self.func(**copied_args)
+
+        with self.revision_manager:
+            if self.revision_manager.updated_revision(revision):
+                self.cached_figure = new_figure
+
+        if update_parent:
+            self.parent.recreate_figure()
+
+    def copy(
+        self,
+        parent: DeephavenNode | DeephavenHeadNode,
+        partitioned_tables: dict[int, tuple[PartitionedTable, DeephavenNode]],
+    ) -> DeephavenFigureNode:
+        """
+        Copy this node
+
+        Args:
+            parent: DeephavenNode | DeephavenHeadNode: The parent node
+            partitioned_tables:
+                dict[int, tuple[PartitionedTable, DeephavenNode]]:
+                A dictionary mapping node ids to partitioned tables and nodes
+
+        Returns:
+            DeephavenFigureNode: The new node
+        """
+        # args need to be copied as the table key is modified
+        new_args = args_copy(self.args)
+        new_node = DeephavenFigureNode(
+            self.parent, self.exec_ctx, new_args, self.table, self.func
+        )
+
+        if id(self) in partitioned_tables:
+            table, _ = partitioned_tables[id(self)]
+            partitioned_tables.pop(id(self))
+            partitioned_tables[id(new_node)] = (table, new_node)
+
+        new_node.cached_figure = self.cached_figure
+        new_node.parent = parent
+        return new_node
+
+    def get_figure(self) -> DeephavenFigure:
+        """
+        Get the figure for this node. It will be generated if not cached
+
+        Returns:
+            DeephavenFigure: The figure for this node
+        """
+        if not self.cached_figure:
+            self.recreate_figure(update_parent=False)
+
+        return self.cached_figure
+
+
+class DeephavenLayerNode(DeephavenNode):
+    """
+    A node in the DeephavenFigure graph that represents a layer.
+
+    Attributes:
+        parent: DeephavenNode | DeephavenHeadNode: The parent node
+        nodes: list[DeephavenNode]: The child nodes
+        layer_func: Callable: The layer function
+        args: dict[str, Any]: The arguments to the function
+        cached_figure: DeephavenFigure: The cached figure
+        exec_ctx: ExecutionContext: The execution context
+        revision_manager: RevisionManager: The revision manager to use for the layer node
+    """
+
+    def __init__(
+        self, layer_func: Callable, args: dict[str, Any], exec_ctx: ExecutionContext
+    ):
+        """
+        Create a new DeephavenLayerNode
+
+        Args:
+            layer_func: Callable: The layer function
+            args: dict[str, Any]: The arguments to the function
+            exec_ctx: ExecutionContext: The execution context
+        """
+        self.parent = None
+        self.nodes = []
+        self.layer_func = layer_func
+        self.args = args
+        self.cached_figure = None
+        self.exec_ctx = exec_ctx
+        self.revision_manager = RevisionManager()
+
+    def recreate_figure(self, update_parent=True) -> None:
+        """
+        Recreate the figure. This is called when the underlying partition
+        or a child node changes
+
+        Args:
+            update_parent: bool: (Default value = True)
+            If the parent should be updated
+        """
+        revision = self.revision_manager.get_revision()
+
+        # release the lock to ensure there is no deadlock
+        # as for some table operations an exclusive lock is required
+        with self.exec_ctx:
+            figs = [node.cached_figure for node in self.nodes]
+            new_figure = self.layer_func(*figs, **self.args)
+
+        with self.revision_manager:
+            if self.revision_manager.updated_revision(revision):
+                self.cached_figure = new_figure
+
+        if update_parent:
+            self.parent.recreate_figure()
+
+    def copy(
+        self,
+        parent: DeephavenNode | DeephavenHeadNode,
+        partitioned_tables: dict[int, tuple[PartitionedTable, DeephavenNode]],
+    ) -> DeephavenLayerNode:
+        """
+        Copy this node and all children nodes
+
+        Args:
+            parent: DeephavenNode | DeephavenHeadNode: The parent node
+            partitioned_tables: dict[int, tuple[PartitionedTable, DeephavenNode]]:
+              A dictionary mapping node ids to partitioned table and nodes that
+              need to be updated
+
+        Returns:
+            DeephavenLayerNode: The new node
+        """
+        new_node = DeephavenLayerNode(self.layer_func, self.args, self.exec_ctx)
+        new_node.nodes = [
+            node.copy(new_node, partitioned_tables) for node in self.nodes
+        ]
+        new_node.cached_figure = self.cached_figure
+        new_node.parent = parent
+        return new_node
+
+    def get_figure(self) -> DeephavenFigure:
+        """
+        Get the figure for this node. It will be generated if not cached
+
+        Returns:
+            DeephavenFigure: The figure for this node
+        """
+        if not self.cached_figure:
+            for node in self.nodes:
+                node.get_figure()
+            self.recreate_figure(update_parent=False)
+
+        return self.cached_figure
+
+
+class DeephavenHeadNode:
+    """
+    A node in the DeephavenFigure graph that represents the head of the graph.
+
+    Attributes:
+        node: DeephavenNode: The child node
+        partitioned_tables: dict[int, tuple[PartitionedTable, DeephavenNode]]:
+            A dictionary mapping node ids to partitioned table and nodes that
+            need to be updated
+        cached_figure: DeephavenFigure: The cached figure
+    """
+
+    def __init__(self):
+        """
+        Create a new DeephavenHeadNode
+        """
+        # there is only one child node of the head, either a layer or a figure
+        self.node = None
+        self.partitioned_tables = {}
+        self.cached_figure = None
+
+    def copy_graph(self) -> DeephavenHeadNode:
+        """
+        Copy this node and all children nodes
+
+        Returns:
+            DeephavenHeadNode: The new head node
+        """
+        new_head = DeephavenHeadNode()
+        new_partitioned_tables = copy(self.partitioned_tables)
+        new_head.node = self.node.copy(new_head, new_partitioned_tables)
+        new_head.partitioned_tables = new_partitioned_tables
+        return new_head
+
+    def recreate_figure(self) -> None:
+        """
+        Recreate the figure. This is called when the underlying partition
+        or a child node changes
+        """
+        self.node.recreate_figure(update_parent=False)
+        self.cached_figure = self.node.cached_figure
+
+    def get_figure(self) -> DeephavenFigure:
+        """
+        Get the figure for this node. This will be called by a communication to get
+        the initial figure.
+        Returns:
+            DeephavenFigure: The figure
+        """
+        if not self.cached_figure and self.node:
+            self.cached_figure = self.node.get_figure()
+        return self.cached_figure
+
+
 class DeephavenFigure:
     """A DeephavenFigure that contains a plotly figure and mapping from Deephaven
     data tables to the plotly figure
 
     Attributes:
-        fig: Figure: (Default value = None) The underlying plotly fig
-        call: Callable: (Default value = None) The (usually) px drawing
-          function
-        call_args: dict[Any]: (Default value = None) The arguments that were
-          used to call px
-        _data_mappings: list[DataMapping]: (Default value = None) A list of data
-          mappings from table column to corresponding plotly variable
-        has_template: bool: (Default value = False) If a template is used
-        has_color: bool: (Default value = False) True if color was manually
-          applied via discrete_color_sequence
-        trace_generator: Generator[dict[str, Any]]: (Default value = None)
-          A generator for modifications to traces
-        has_subplots: bool: (Default value = False) True if has subplots
+        _head_node: DeephavenHeadNode: The head node of the graph
+        _plotly_fig: Figure: The plotly figure
+        _trace_generator: Generator[dict[str, Any]]: The trace generator
+        _has_template: bool: If this figure has a template
+        _has_color: bool: If this figure has color
+        _data_mappings: list[DataMapping]: The data mappings
+        _has_subplots: bool: If this figure has subplots
+        _liveness_scope: LivenessScope: The liveness scope to use for the figure
     """
 
     def __init__(
         self: DeephavenFigure,
         fig: Figure = None,
-        call: Callable = None,
         call_args: dict[Any] = None,
         data_mappings: list[DataMapping] = None,
         has_template: bool = False,
@@ -94,21 +389,40 @@ def __init__(
         trace_generator: Generator[dict[str, Any]] = None,
         has_subplots: bool = False,
     ):
-        # keep track of function that called this and it's args
-        self.fig = fig
-        self.call = call
-        self.call_args = call_args
-        self.trace_generator = trace_generator
+        """
+        Create a new DeephavenFigure
+
+        Args:
+            fig: Figure: The plotly figure
+            call_args: dict[Any]: Call args, used to determine if the figure
+                has a template or color
+            data_mappings: list[DataMapping]: The data mappings
+            has_template: bool: If this figure has a template
+            has_color: bool: If this figure has color
+            trace_generator: Generator[dict[str, Any]]: The trace generator
+            has_subplots: bool: If this figure has subplots
+        """
+        # keep track of function that called this, and it's args
+        self._head_node = DeephavenHeadNode()
 
-        self.has_template = (
+        # note: these variables might not be up-to-date with the latest
+        # figure if the figure is updated
+        self._plotly_fig = fig
+        self._trace_generator = trace_generator
+
+        self._has_template = (
             has_template if has_template else has_arg(call_args, "template")
         )
 
-        self.has_color = has_color if has_color else has_arg(call_args, has_color_args)
+        self._has_color = has_color if has_color else has_arg(call_args, has_color_args)
+
+        self._call_args = call_args
 
         self._data_mappings = data_mappings if data_mappings else []
 
-        self.has_subplots = has_subplots
+        self._has_subplots = has_subplots
+
+        self._liveness_scope = LivenessScope()
 
     def copy_mappings(self: DeephavenFigure, offset: int = 0) -> list[DataMapping]:
         """Copy all DataMappings within this figure, adding a specific offset
@@ -164,12 +478,197 @@ def to_json(self: DeephavenFigure, exporter: Exporter) -> str:
           str: The DeephavenFigure as a JSON string
 
         """
-        plotly = json.loads(self.fig.to_json())
+        plotly = json.loads(self._plotly_fig.to_json())
         mappings = self.get_json_links(exporter)
         deephaven = {
             "mappings": mappings,
-            "is_user_set_template": self.has_template,
-            "is_user_set_color": self.has_color,
+            "is_user_set_template": self._has_template,
+            "is_user_set_color": self._has_color,
         }
         payload = {"plotly": plotly, "deephaven": deephaven}
         return json.dumps(payload)
+
+    def add_layer_to_graph(
+        self, layer_func: Callable, args: dict[str, Any], exec_ctx: ExecutionContext
+    ) -> None:
+        """
+        Add a layer to the graph
+
+        Args:
+            layer_func: Callable: The layer function
+            args: dict[str, Any]: The arguments to the function
+            exec_ctx: ExecutionContext: The execution context
+        """
+
+        new_head = DeephavenHeadNode()
+        self._head_node = new_head
+
+        layer_node = DeephavenLayerNode(layer_func, args, exec_ctx)
+
+        new_head.node = layer_node
+        layer_node.parent = new_head
+
+        figs = args.pop("figs")
+        children = []
+        partitioned_tables = {}
+        for fig in figs:
+            if isinstance(fig, Figure):
+                new_node = DeephavenFigureNode()
+                children.append(new_node)
+                # this is a plotly figure, so we need to create a new node
+                # but we don't need to recreate it ever
+                new_node.cached_figure = fig
+                pass
+            elif isinstance(fig, DeephavenFigure):
+                tmp_head = fig._head_node.copy_graph()
+                children.append(tmp_head.node)
+                partitioned_tables.update(tmp_head.partitioned_tables)
+
+        layer_node.nodes = children
+        new_head.partitioned_tables = partitioned_tables
+
+        for node in children:
+            node.parent = layer_node
+
+    def add_figure_to_graph(
+        self,
+        exec_ctx: ExecutionContext,
+        args: dict[str, Any],
+        table: Table | PartitionedTable,
+        key_column_table: Table,
+        func: Callable,
+    ) -> None:
+        """
+        Add a figure to the graph. It is assumed that this is the first figure
+
+        Args:
+            exec_ctx: ExecutionContext: The execution context
+            args: dict[str, Any]: The arguments to the function
+            table: Table | PartitionedTable: The table to pull data from
+            key_column_table: Table: The table with partitions, used by the DeephavenFigureListener
+            func: Callable: The function to call
+
+        """
+        self._liveness_scope.manage(table)
+
+        node = DeephavenFigureNode(self._head_node, exec_ctx, args, table, func)
+
+        partitioned_tables = {}
+        if isinstance(table, PartitionedTable):
+            partitioned_tables = {id(node): (key_column_table, node)}
+
+        self._head_node.node = node
+        self._head_node.partitioned_tables = partitioned_tables
+
+    def get_head_node(self) -> DeephavenHeadNode:
+        """
+        Get the head node for this figure
+
+        Returns:
+            DeephavenHeadNode: The head node
+
+        """
+        return self._head_node
+
+    def get_figure(self) -> DeephavenFigure:
+        """
+        Get the true DeephavenFigure for this figure.
+
+        Returns:
+            DeephavenFigure: The figure
+        """
+        return self._head_node.get_figure()
+
+    def get_plotly_fig(self) -> Figure:
+        """
+        Get the plotly figure for this figure
+
+        Returns:
+            Figure: The plotly figure
+        """
+        if not self.get_figure():
+            return self._plotly_fig
+        return self.get_figure().get_plotly_fig()
+
+    def get_data_mappings(self) -> list[DataMapping]:
+        """
+        Get the data mappings for this figure
+
+        Returns:
+            list[DataMapping]: The data mappings
+        """
+        if not self.get_figure():
+            return self._data_mappings
+        return self.get_figure().get_data_mappings()
+
+    def get_trace_generator(self) -> Generator[dict[str, Any]]:
+        """
+        Get the trace generator for this figure
+
+        Returns:
+            Generator[dict[str, Any]]: The trace generator
+        """
+        if not self.get_figure():
+            return self._trace_generator
+        return self.get_figure().get_trace_generator()
+
+    def get_has_template(self) -> bool:
+        """
+        Get if this figure has a template
+
+        Returns:
+            bool: True if has a template, False otherwise
+        """
+        if not self.get_figure():
+            return self._has_template
+        return self.get_figure().get_has_template()
+
+    def get_has_color(self) -> bool:
+        """
+        Get if this figure has color
+
+        Returns:
+            bool: True if has color, False otherwise
+        """
+        if not self.get_figure():
+            return self._has_color
+        return self.get_figure().get_has_color()
+
+    def get_has_subplots(self) -> bool:
+        """
+        Get if this figure has subplots
+
+        Returns:
+            bool: True if has subplots, False otherwise
+        """
+        if not self.get_figure():
+            return self._has_subplots
+        return self.get_figure().get_has_subplots()
+
+    def __del__(self):
+        self._liveness_scope.release()
+
+    def copy(self) -> DeephavenFigure:
+        """
+        Copy the figure
+
+        Returns:
+            DeephavenFigure: The new figure
+        """
+        new_figure = DeephavenFigure(
+            self._plotly_fig,
+            self._call_args,
+            self._data_mappings,
+            self._has_template,
+            self._has_color,
+            self._trace_generator,
+            self._has_subplots,
+        )
+        new_figure._head_node = self._head_node.copy_graph()
+        return new_figure
+
+    def recreate_figure(self) -> None:
+        """
+        Recreate the figure. This is called to ensure the figure is up-to-date
+        """
+        self._head_node.recreate_figure()
diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/RevisionManager.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/RevisionManager.py
new file mode 100644
index 000000000..975b6c76e
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/RevisionManager.py
@@ -0,0 +1,58 @@
+import threading
+
+
+class RevisionManager:
+    """
+    A manager for revisions. This is used to ensure that revisions are correctly ordered
+    without overlap. Note that the RevisionManager context manager locks, and
+    generally table operations should not be done within the body.
+
+    Attributes:
+        lock: threading.Lock: The lock to use for the manager
+        rev_lock: threading.Lock: The lock to use for the revision. Note that
+        this is a different lock than the one used for the manager, as
+        acquiring a revision number should not block creating a new revision
+        revision: int: The last revision that was assigned
+        current_revision: int: The current revision that has been applied
+    """
+
+    def __init__(self):
+        self.lock = threading.Lock()
+        self.rev_lock = threading.Lock()
+        self.revision = 0
+        self.current_revision = 0
+
+    def __enter__(self):
+        self.lock.acquire()
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.lock.release()
+
+    def get_revision(self) -> int:
+        """
+        Get the next revision
+
+        Returns:
+            int: The next revision
+
+        """
+        with self.rev_lock:
+            self.revision += 1
+            return self.revision
+
+    def updated_revision(self, revision: int) -> bool:
+        """
+        Update the current revision if the passed revision is greater than the
+        current revision. This should be called with the node lock held
+
+        Args:
+            revision: int: The revision to update to
+
+        Returns:
+            bool: True if the revision was updated, False otherwise
+
+        """
+        can_update = self.current_revision < revision
+        if can_update:
+            self.current_revision = revision
+        return can_update
diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py
index c163cbc44..ba8e38695 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/__init__.py
@@ -1,3 +1,7 @@
-from .DeephavenFigure import DeephavenFigure, export_figure
+from .DeephavenFigure import (
+    DeephavenFigure,
+    DeephavenFigureNode,
+)
 from .generate import generate_figure, update_traces
 from .custom_draw import draw_ohlc, draw_candlestick
+from .RevisionManager import RevisionManager
diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py
index e7a2c7715..4b24bef47 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py
@@ -115,6 +115,7 @@
     "pivot_vars",
     "current_partition",
     "colors",
+    "unsafe_update_figure",
 }
 
 # these are columns that are "attached" sequentially to the traces
@@ -1014,7 +1015,6 @@ def generate_figure(
     dh_fig = DeephavenFigure(
         px_fig,
         call_args=call_args,
-        call=draw,
         data_mappings=[data_mapping],
         trace_generator=trace_generator,
     )
diff --git a/plugins/plotly-express/src/deephaven/plot/express/exporter/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/exporter/__init__.py
new file mode 100644
index 000000000..e62ac721b
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/exporter/__init__.py
@@ -0,0 +1,1 @@
+from .export import Exporter
diff --git a/plugins/plotly-express/src/deephaven/plot/express/exporter/export.py b/plugins/plotly-express/src/deephaven/plot/express/exporter/export.py
new file mode 100644
index 000000000..30241ce5b
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/exporter/export.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from typing import Any
+import threading
+
+
+class Reference:
+    """A reference to an object
+
+    Attributes:
+        id: int: The id of the reference
+        obj: object: The object that the reference points to
+    """
+
+    def __init__(self, index: int, obj: object):
+        """
+        Create a new reference
+        Args:
+            index: int: The index of the reference
+            obj: object: The object that the reference points to
+        """
+        self.id = index
+        self.obj = obj
+
+
+class Exporter:
+    """
+    An exporter that keeps track of references to objects that need to be sent
+
+    Attributes:
+        _ref_count: int: The number of references that have been created.
+            Acts as an id for the next reference
+        _references: dict[object, Reference]: A map of objects to their
+            references
+        _new_references: list[int]: A list of new references that have been
+            created
+        _new_objects: list[object]: A list of new objects that have been
+            created
+        _used_references: set[int]: A set of references that have been used
+        _revision: int: The current revision. Used to ensure that revisions are
+            correctly ordered
+    """
+
+    def __init__(self):
+        self._ref_count: int = 0
+        self._references: dict[object, Reference] = {}
+        self._new_references: list[int] = []
+        self._new_objects: list[object] = []
+        self._used_references: set[int] = set()
+        self._revision: int = 0
+
+    def reference(self, obj: object) -> Reference:
+        """
+        Get a reference to an object
+
+        Args:
+            obj: The object to get a reference to
+
+        Returns:
+            Reference: The reference to the object
+        """
+        if obj not in self._references:
+            new_ref_id = self._ref_count
+            self._ref_count += 1
+            self._references[obj] = Reference(new_ref_id, obj)
+            self._new_references.append(new_ref_id)
+            self._new_objects.append(obj)
+        self._used_references.add(self._references[obj].id)
+        return self._references[obj]
+
+    def references(self) -> tuple[list[Any], list[int], list[int]]:
+        """
+        Creates a tuple of the new objects, new references, and removed
+            references
+
+        Returns:
+            tuple[list[Any], list[int], list[int]]: The tuple of new objects,
+                new references, and removed references
+
+        """
+        removed_references = []
+        for obj, ref in list(self._references.items()):
+            if ref.id not in self._used_references:
+                removed_references.append(ref.id)
+                self._references.pop(obj)
+
+        new_references = self._new_references
+        new_objects = self._new_objects
+        self._new_objects = []
+        self._new_references = []
+        self._used_references = set()
+
+        return new_objects, new_references, removed_references
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py b/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py
index adb42b4f0..bf6e8e989 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/PartitionManager.py
@@ -8,9 +8,9 @@
 
 from deephaven.table import Table, PartitionedTable
 from deephaven import pandas as dhpd
-from deephaven import merge
+from deephaven import merge, empty_table
 
-from ._layer import layer
+from ._layer import atomic_layer
 from .. import DeephavenFigure
 from ..preprocess.Preprocessor import Preprocessor
 from ..shared import get_unique_names
@@ -50,6 +50,7 @@ def get_partition_key_column_tuples(
     key_column_table: DataFrame, columns: list[str]
 ) -> list[tuple[Any]]:
     """
+    Get partition key column tuples from a table
 
     Args:
         key_column_table: DataFrame: The table containing the key columns
@@ -66,7 +67,7 @@ def get_partition_key_column_tuples(
 
 
 def numeric_column_set(
-    table: Table,
+    table: Table | PartitionedTable,
 ) -> set[str]:
     """Gets the set of numeric columns in the table
 
@@ -76,8 +77,11 @@ def numeric_column_set(
     Returns:
       set[str]: set of numeric columns
     """
+    cols = (
+        table.columns if isinstance(table, Table) else table.constituent_table_columns
+    )
     numeric_cols = set()
-    for col in table.columns:
+    for col in cols:
         type_ = col.data_type.j_name
         if type_ in NUMERIC_TYPES:
             numeric_cols.add(col.name)
@@ -128,6 +132,7 @@ class PartitionManager:
         partitioned_table: PartitionedTable: The partitioned table created (or
           passed in if already created)
         draw_figure: Callable: The function used to draw the figure
+        constituents: list[Table]: The list of constituent tables
     """
 
     def __init__(
@@ -138,7 +143,7 @@ def __init__(
         marg_args: dict[str, any],
         marg_func: Callable,
     ):
-
+        self.by = None
         self.by_vars = None
         self.list_var = None
         self.cols = None
@@ -159,8 +164,10 @@ def __init__(
         self.preprocessor = None
         self.set_long_mode_variables()
         self.convert_table_to_long_mode()
+        self.key_column_table = None
         self.partitioned_table = self.process_partitions()
         self.draw_figure = draw_figure
+        self.constituents = None
 
     def set_long_mode_variables(self) -> None:
         """
@@ -282,7 +289,6 @@ def handle_plot_by_arg(
         """
         args = self.args
         table = args["table"]
-        table = table if isinstance(table, Table) else table.constituent_tables[0]
         numeric_cols = numeric_column_set(table)
 
         plot_by_cols = args.get("by", None)
@@ -378,8 +384,12 @@ def process_partitions(self) -> Table | PartitionedTable:
         if isinstance(args["table"], PartitionedTable):
             partitioned_table = args["table"]
 
+        # save the by arg so it can be reused in renders,
+        # especially if it was overriden
+        self.by = args.get("by", None)
+
         for arg, val in list(args.items()):
-            if (val or args.get("by", None)) and arg in PARTITION_ARGS:
+            if (val or self.by) and arg in PARTITION_ARGS:
                 arg_by, cols = self.handle_plot_by_arg(arg, val)
                 if cols:
                     partition_map[arg_by] = cols
@@ -394,11 +404,10 @@ def process_partitions(self) -> Table | PartitionedTable:
                 else:
                     self.facet_col = val
 
-        # it's possible that pivot vars are set but by_vars is None,
-        # so partitioning is still needed on that column but it won't
-        # affect styles
-        if self.pivot_vars:
-            partition_cols.add(self.pivot_vars["variable"])
+        # it's possible that by vars are set but by_vars is None,
+        # so partitioning is still needed but it won't affect styles
+        if not self.by_vars and self.by:
+            partition_cols.update(self.by if isinstance(self.by, list) else [self.by])
 
         # preprocessor needs to be initialized after the always attached arguments are found
         self.preprocessor = Preprocessor(
@@ -408,10 +417,13 @@ def process_partitions(self) -> Table | PartitionedTable:
         if partition_cols:
             if not partitioned_table:
                 partitioned_table = args["table"].partition_by(list(partition_cols))
+            if not self.key_column_table:
+                self.key_column_table = partitioned_table.table.drop_columns(
+                    "__CONSTITUENT__"
+                )
+
+            key_column_pandas = dhpd.to_pandas(self.key_column_table)
 
-            key_column_table = dhpd.to_pandas(
-                partitioned_table.table.select_distinct(partitioned_table.key_columns)
-            )
             for arg_by, val in partition_map.items():
                 # remove "by" from arg
                 arg = arg_by[:-3]
@@ -419,7 +431,7 @@ def process_partitions(self) -> Table | PartitionedTable:
                     # replace the sequence with the sequence, map and distinct keys
                     # so they can be easily used together
                     keys = get_partition_key_column_tuples(
-                        key_column_table, val if isinstance(val, list) else [val]
+                        key_column_pandas, val if isinstance(val, list) else [val]
                     )
                     sequence, map_ = PARTITION_ARGS[arg]
                     args[sequence] = {
@@ -489,16 +501,24 @@ def current_partition_generator(self) -> Generator[dict[str, str]]:
         Yields:
             dict[str, str]: The partition dictionary mapping column to value
         """
-        for table in self.partitioned_table.constituent_tables:
-            key_column_table = dhpd.to_pandas(
-                table.select_distinct(self.partitioned_table.key_columns)
+        for table in self.constituents:
+            # sort the columns so the order is consistent
+            key_columns = self.partitioned_table.key_columns
+            key_columns.sort()
+
+            key_column_table = dhpd.to_pandas(table.select(key_columns))
+            key_column_tuples = get_partition_key_column_tuples(
+                key_column_table, key_columns
             )
+
+            if len(key_column_tuples) < 1:
+                # this partition might have no data, so skip it
+                continue
+
             current_partition = dict(
                 zip(
-                    self.partitioned_table.key_columns,
-                    get_partition_key_column_tuples(
-                        key_column_table, self.partitioned_table.key_columns
-                    )[0],
+                    key_columns,
+                    key_column_tuples[0],
                 )
             )
             yield current_partition
@@ -512,9 +532,10 @@ def table_partition_generator(self) -> Generator[tuple[Table, dict[str, str]]]:
             tuple[Table, dict[str, str]: The tuple of table and current partition
 
         """
-        constituents = self.partitioned_table.constituent_tables
         column = self.pivot_vars["value"] if self.pivot_vars else None
-        tables = self.preprocessor.preprocess_partitioned_tables(constituents, column)
+        tables = self.preprocessor.preprocess_partitioned_tables(
+            self.constituents, column
+        )
         for table, current_partition in zip(tables, self.current_partition_generator()):
             yield table, current_partition
 
@@ -556,6 +577,20 @@ def partition_generator(self) -> Generator[dict[str, Any]]:
         else:
             yield args
 
+    def default_figure(self) -> DeephavenFigure:
+        """
+        Create a default figure if there are no partitions
+
+        Returns:
+            DeephavenFigure: The default figure
+        """
+        # this is very hacky but it's needed to prevent errors when
+        # there are no partitions until a better solution can be done
+        # also need the px template to be set
+        default_fig = px.scatter(x=[0], y=[0])
+        default_fig.update_traces(x=[], y=[])
+        return DeephavenFigure(default_fig)
+
     def create_figure(self) -> DeephavenFigure:
         """
         Create a figure. This handles layering different partitions as necessary as well
@@ -564,19 +599,26 @@ def create_figure(self) -> DeephavenFigure:
         Returns:
             DeephavenFigure: The new figure
         """
+        if isinstance(self.partitioned_table, PartitionedTable):
+            # lock constituents in case they are deleted
+            self.constituents = [*self.partitioned_table.constituent_tables]
+
+            if len(self.constituents) == 0:
+                return self.default_figure()
+
         trace_generator = None
         figs = []
         for i, args in enumerate(self.partition_generator()):
             fig = self.draw_figure(call_args=args, trace_generator=trace_generator)
             if not trace_generator:
-                trace_generator = fig.trace_generator
+                trace_generator = fig.get_trace_generator()
 
             facet_key = []
             if "current_partition" in args:
                 partition = args["current_partition"]
                 if (
                     "preprocess_hist" in self.groups
-                    or "preprocess_violin" in self.groups
+                    or "preprocess_spread" in self.groups
                 ):
                     # offsetgroup is needed mostly to prevent spacing issues in
                     # marginals
@@ -584,7 +626,7 @@ def create_figure(self) -> DeephavenFigure:
                     # violin, etc. leads to extra spacing in each marginal
                     # offsetgroup needs to be unique within the subchart as columns
                     # could have the same name
-                    fig.fig.update_traces(
+                    fig.get_plotly_fig().update_traces(
                         offsetgroup=f"{'-'.join(args['current_partition'])}{i}"
                     )
                 facet_key.extend(
@@ -595,18 +637,21 @@ def create_figure(self) -> DeephavenFigure:
                 )
             facet_key = tuple(facet_key)
 
-            if "preprocess_hist" in self.groups or "preprocess_violin" in self.groups:
+            if "preprocess_hist" in self.groups or "preprocess_spread" in self.groups:
                 if "current_partition" in args:
-                    fig.fig.update_layout(legend_tracegroupgap=0)
+                    fig.get_plotly_fig().update_layout(legend_tracegroupgap=0)
                 else:
-                    fig.fig.update_layout(showlegend=False)
+                    fig.get_plotly_fig().update_layout(showlegend=False)
 
             figs.append(fig)
 
-        layered_fig = layer(*figs, which_layout=0)
+        try:
+            layered_fig = atomic_layer(*figs, which_layout=0)
+        except ValueError:
+            return self.default_figure()
 
         if self.has_color is False:
-            layered_fig.has_color = False
+            layered_fig._has_color = False
 
         if self.marg_args:
             # the marginals need to use the already partitioned table as they
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py
index 81ae71443..72d98092f 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_layer.py
@@ -1,12 +1,13 @@
 from __future__ import annotations
 
 from functools import partial
-from typing import Any
+from typing import Any, Callable
 
 from plotly.graph_objs import Figure
 
 from ..deephaven_figure import DeephavenFigure
-from ._update_wrapper import default_callback, unsafe_figure_update_wrapper
+from ..shared import default_callback, unsafe_figure_update_wrapper
+from deephaven.execution_context import make_user_exec_ctx
 
 
 def normalize_position(
@@ -415,41 +416,29 @@ def fig_data_and_layout(
     return fig.data, fig_layout
 
 
-def layer(
+def atomic_layer(
     *figs: DeephavenFigure | Figure,
     which_layout: int = None,
     specs: list[dict[str, Any]] = None,
     unsafe_update_figure: callable = default_callback,
 ) -> DeephavenFigure:
-    """Layers the provided figures. Be default, the layouts are sequentially
-    applied, so the layouts of later figures will override the layouts of early
-    figures.
+    """
+    Layers the provided figures. This is an atomic version of layer, so the
+    figures will be layered immediately and not added to the graph. This is
+    used by plot by.
+    See layer for more details about arguments.
 
     Args:
-      *figs: DeephavenFigure | Figure: The charts to layer
-      which_layout: int:  (Default value = None) None to layer layouts, or an
-        index of which arg to take the layout from. Currently only valid if
-        domains are not specified.
-      specs: list[dict[str, str | bool | list[float]]]:
-        A list of dictionaries that contains keys of "x" and "y"
-        that have values that are lists of two floats from 0 to 1. The chart
-        that corresponds with a domain will be resized to that domain. Either
-        x or y can be excluded if only resizing on one axis.
-        Can also specify "xaxis_update" or "yaxis_update" with a dictionary
-        value to update all axes with that dict.
-        Can also specify "matched_xaxis" or "matched_yaxis" to add this figure
-        to a match group. All figures with the same value of this group will
-        have matching axes.
-      unsafe_update_figure: An update function that takes a plotly figure
-        as an argument and optionally returns a plotly figure. If a figure is not
-        returned, the plotly figure passed will be assumed to be the return value.
-        Used to add any custom changes to the underlying plotly figure. Note that
-        the existing data traces should not be removed. This may lead to unexpected
-        behavior if traces are modified in a way that break data mappings.
+        *figs: DeephavenFigure | Figure: The charts to layer
+        which_layout: int:  (Default value = None):
+            See layer
+        specs: list[dict[str, str | bool | list[float]]]:
+            See layer
+        unsafe_update_figure:
+            See layer
 
     Returns:
-      DeephavenFigure: The layered chart
-
+        DeephavenFigure: The layered chart
     """
     if len(figs) == 0:
         raise ValueError("No figures provided to compose")
@@ -476,16 +465,21 @@ def layer(
 
         elif isinstance(arg, DeephavenFigure):
             offset = len(new_data)
-            if arg.has_subplots:
+            if arg.get_has_subplots():
                 raise NotImplementedError(
                     "Cannot currently add figure with subplots as a subplot"
                 )
             fig_data, fig_layout = fig_data_and_layout(
-                arg.fig, i, specs, which_layout, new_axes_start, matches_axes
+                arg.get_plotly_fig(),
+                i,
+                specs,
+                which_layout,
+                new_axes_start,
+                matches_axes,
             )
             new_data_mappings += arg.copy_mappings(offset=offset)
-            new_has_template = arg.has_template or new_has_template
-            new_has_color = arg.has_color or new_has_color
+            new_has_template = arg.get_has_template() or new_has_template
+            new_has_color = arg.get_has_color() or new_has_color
 
         else:
             raise TypeError("All arguments must be of type Figure or DeephavenFigure")
@@ -497,7 +491,6 @@ def layer(
 
     update_wrapper = partial(unsafe_figure_update_wrapper, unsafe_update_figure)
 
-    # todo: this doesn't maintain call args, but that isn't currently needed
     return update_wrapper(
         DeephavenFigure(
             fig=new_fig,
@@ -507,3 +500,61 @@ def layer(
             has_subplots=True if specs else False,
         )
     )
+
+
+def layer(
+    *figs: DeephavenFigure | Figure,
+    which_layout: int = None,
+    specs: list[dict[str, Any]] = None,
+    unsafe_update_figure: Callable = default_callback,
+) -> DeephavenFigure:
+    """Layers the provided figures. Be default, the layouts are sequentially
+    applied, so the layouts of later figures will override the layouts of early
+    figures.
+
+    Args:
+      *figs: DeephavenFigure | Figure: The charts to layer
+      which_layout: int:  (Default value = None) None to layer layouts, or an
+        index of which arg to take the layout from. Currently only valid if
+        domains are not specified.
+      specs: list[dict[str, str | bool | list[float]]]:
+        A list of dictionaries that contains keys of "x" and "y"
+        that have values that are lists of two floats from 0 to 1. The chart
+        that corresponds with a domain will be resized to that domain. Either
+        x or y can be excluded if only resizing on one axis.
+        Can also specify "xaxis_update" or "yaxis_update" with a dictionary
+        value to update all axes with that dict.
+        Can also specify "matched_xaxis" or "matched_yaxis" to add this figure
+        to a match group. All figures with the same value of this group will
+        have matching axes.
+        atomic: bool:  (Default value = False) If True, this layer call will be
+        treated as an atomic part of a figure creation call, and the figure will not be updated until
+        This should almost certainly always be False
+      unsafe_update_figure: Callable: An update function that takes a plotly figure
+        as an argument and optionally returns a plotly figure. If a figure is not
+        returned, the plotly figure passed will be assumed to be the return value.
+        Used to add any custom changes to the underlying plotly figure. Note that
+        the existing data traces should not be removed. This may lead to unexpected
+        behavior if traces are modified in a way that break data mappings.
+
+    Returns:
+      DeephavenFigure: The layered chart
+
+    """
+
+    args = locals()
+
+    func = atomic_layer
+
+    new_fig = atomic_layer(
+        *figs,
+        which_layout=which_layout,
+        specs=specs,
+        unsafe_update_figure=unsafe_update_figure,
+    )
+
+    exec_ctx = make_user_exec_ctx()
+
+    new_fig.add_layer_to_graph(func, args, exec_ctx)
+
+    return new_fig
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py
index e907d6b13..0c5976370 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py
@@ -7,12 +7,20 @@
 import plotly.express as px
 
 from deephaven.table import Table, PartitionedTable
+from deephaven.execution_context import make_user_exec_ctx
 
 from ._layer import layer
 from .PartitionManager import PartitionManager
-from ._update_wrapper import unsafe_figure_update_wrapper
 from ..deephaven_figure import generate_figure, DeephavenFigure
-from ._update_wrapper import default_callback
+from ..shared import args_copy, unsafe_figure_update_wrapper
+from ..shared.distribution_args import (
+    SHARED_DEFAULTS,
+    VIOLIN_DEFAULTS,
+    BOX_DEFAULTS,
+    STRIP_DEFAULTS,
+    HISTOGRAM_DEFAULTS,
+    SPREAD_GROUPS,
+)
 
 
 def validate_common_args(args: dict) -> None:
@@ -22,6 +30,7 @@ def validate_common_args(args: dict) -> None:
       args: dict: The args to validate
 
     """
+
     if not isinstance(args["table"], (Table, PartitionedTable)):
         raise ValueError("Argument table is not of type Table")
 
@@ -158,14 +167,14 @@ def apply_args_groups(args: dict[str, Any], groups: set[str]) -> None:
     sync_dict.sync_pop()
 
 
-def process_args(
+def create_deephaven_figure(
     args: dict[str, Any],
     groups: set[str] = None,
     add: dict[str, Any] = None,
     pop: list[str] = None,
     remap: dict[str, str] = None,
-    px_func=Callable,
-) -> DeephavenFigure:
+    px_func: Callable = None,
+) -> tuple[DeephavenFigure, Table | PartitionedTable, Table, dict[str, Any]]:
     """Process the provided args
 
     Args:
@@ -181,7 +190,9 @@ def process_args(
       px_func: Callable: the function (generally from px) to use to create the figure
 
     Returns:
-      partial: The new figure
+      tuple[DeephavenFigure, Table | PartitionedTable]: A tuple of the figure, the table, a table to listen to, and an
+      update that needs to be applied to all calls that regenerate this figure. If "by" is set, there is a plot by
+      column. If "x" or "y" are set, there is a list variable which needs to be replaced.
 
     """
     validate_common_args(args)
@@ -211,29 +222,104 @@ def process_args(
         for old_arg, new_arg in remap.items():
             args[new_arg] = args.pop(old_arg)
 
-    update_wrapper = partial(
-        unsafe_figure_update_wrapper, args.pop("unsafe_update_figure")
+    update_wrapper = partial(unsafe_figure_update_wrapper, args["unsafe_update_figure"])
+
+    list_var = partitioned.list_var
+    pivot_col = partitioned.pivot_vars["value"] if partitioned.pivot_vars else None
+    by = partitioned.by
+
+    update = {}
+
+    if by:
+        # by needs to be updated as if there is a list variable but by is None, the pivot column is used as the by
+        update["by"] = by
+
+    if list_var:
+        # if there is a list variable, update the list variable to the pivot column
+        update[list_var] = pivot_col
+
+    return (
+        update_wrapper(partitioned.create_figure()),
+        partitioned.partitioned_table,
+        partitioned.key_column_table,
+        update,
+    )
+
+
+def process_args(
+    args: dict[str, Any],
+    groups: set[str] = None,
+    add: dict[str, Any] = None,
+    pop: list[str] = None,
+    remap: dict[str, str] = None,
+    px_func: Callable = None,
+) -> DeephavenFigure:
+    """Process the provided args
+
+    Args:
+      args: dict[str, Any]: A dictionary of args to process
+      groups: set[str]:  (Default value = None)
+        A set of groups that apply transformations to the args
+      add: dict[str, Any] (Default value = None)
+        A dictionary to add to the args
+      pop: list[str]:  (Default value = None)
+        A list of keys to remove from the args
+      remap: dict[str, str]:  (Default value = None)
+        A dictionary mapping of keys to keys
+      px_func: Callable: the function (generally from px) to use to create the figure
+
+    Returns:
+        DeephavenFigure: The new figure
+
+    """
+    use_args = locals()
+    orig_process_args = args_copy(use_args)
+    orig_process_func = lambda **local_args: create_deephaven_figure(**local_args)[0]
+
+    new_fig, table, key_column_table, update = create_deephaven_figure(**use_args)
+
+    orig_process_args["args"].update(update)
+
+    exec_ctx = make_user_exec_ctx()
+
+    # these are needed for when partitions are added
+    new_fig.add_figure_to_graph(
+        exec_ctx, orig_process_args, table, key_column_table, orig_process_func
     )
 
-    return update_wrapper(partitioned.create_figure())
+    return new_fig
 
 
 class SyncDict:
     """A dictionary wrapper that will queue up keys to remove and remove them
     all at once
 
-
-    Args:
+    Attributes:
       d: dict: the dictionary to wrap
-
+      pop_set: set: the set of keys to pop
 
     """
 
     def __init__(self, d: dict):
+        """
+        Create a SyncDict
+        Args:
+            d: dict: The dictionary to wrap
+        """
         self.d = d
         self.pop_set = set()
 
-    def __contains__(self, item):
+    def __contains__(self, item: Any) -> bool:
+        """
+        Check if the item is in the dictionary
+
+        Args:
+            item: Any: The item to check
+
+        Returns:
+            bool: True if the item is in the dictionary, False otherwise
+
+        """
         return item in self.d
 
     def will_pop(self, key: Any) -> Any:
@@ -258,6 +344,18 @@ def sync_pop(self):
             self.d.pop(k)
 
 
+def set_all(args: dict[str, Any], pairs: dict[str, Any]) -> None:
+    """
+    Set all the pairs in the args if they are not already set
+
+    Args:
+        args: dict[str, Any]: The args to set the pairs on
+        pairs: dict[str, Any]: The pairs to set
+    """
+    for k, v in pairs:
+        args.get(k, v)
+
+
 def set_shared_defaults(args: dict[str, Any]) -> None:
     """
     Set shared defaults amongst distribution figures
@@ -265,86 +363,113 @@ def set_shared_defaults(args: dict[str, Any]) -> None:
     Args:
         args: dict[str, str]: The args to set the shared defaults on
     """
-    args["by_vars"] = args.get("by_vars", ("color",))
-    args["unsafe_update_figure"] = args.get("unsafe_update_figure", default_callback)
-    args["x"] = args.get("x", None)
-    args["y"] = args.get("y", None)
+    set_all(args, SHARED_DEFAULTS)
+
+
+def shared_marginal(
+    marginal: bool, func: Callable, groups: set[str], **args: Any
+) -> DeephavenFigure:
+    """
+    Create a marginal figure
+
+    Args:
+        marginal: True if this is a marginal figure, False otherwise
+        func: The function to use to create the figure
+        groups: The groups to apply to the figure
+        **args: Other args to pass to the figure
+
+    Returns:
+        The DeephavenFigure created
+    """
+    if not marginal:
+        return process_args(args, groups, px_func=func)
+    return create_deephaven_figure(args, groups, px_func=func)[0]
 
 
-def shared_violin(**args: Any) -> DeephavenFigure:
+def shared_violin(
+    marginal=True,
+    **args: Any,
+) -> DeephavenFigure:
     """
+    Create a violin figure
 
     Args:
+                marginal: bool:  (Default value = True) Whether this is a marginal figure or not
         **args: Any: The args used for the figure
 
     Returns:
         The DeephavenFigure created
     """
     set_shared_defaults(args)
-    args["violinmode"] = args.get("violinmode", "group")
-    args["points"] = args.get("points", "outliers")
-    return process_args(
-        args, {"marker", "preprocess_violin", "supports_lists"}, px_func=px.violin
-    )
+    set_all(args, VIOLIN_DEFAULTS)
 
+    func = px.violin
+    groups = SPREAD_GROUPS
 
-def shared_box(**args: Any) -> DeephavenFigure:
+    return shared_marginal(marginal, func, groups, **args)
+
+
+def shared_box(marginal=True, **args: Any) -> DeephavenFigure:
     """
+    Create a box figure
 
     Args:
+        marginal: bool:  (Default value = True) Whether this is a marginal figure or not
         **args: Any: The args used for the figure
 
     Returns:
         The DeephavenFigure created
     """
     set_shared_defaults(args)
-    args["boxmode"] = args.get("boxmode", "group")
-    args["points"] = args.get("points", "outliers")
-    return process_args(
-        args, {"marker", "preprocess_violin", "supports_lists"}, px_func=px.box
-    )
+    set_all(args, BOX_DEFAULTS)
+
+    func = px.box
+    groups = SPREAD_GROUPS
+
+    return shared_marginal(marginal, func, groups, **args)
 
 
-def shared_strip(**args: Any) -> DeephavenFigure:
+def shared_strip(marginal=True, **args: Any) -> DeephavenFigure:
     """
+    Create a strip figure
 
     Args:
+        marginal: bool:  (Default value = True) Whether this is a marginal figure or not
         **args: Any: The args used for the figure
 
     Returns:
         The DeephavenFigure created
     """
     set_shared_defaults(args)
-    args["stripmode"] = args.get("stripmode", "group")
-    return process_args(
-        args, {"marker", "preprocess_violin", "supports_lists"}, px_func=px.strip
-    )
+    set_all(args, STRIP_DEFAULTS)
+
+    func = px.strip
+    groups = SPREAD_GROUPS
+
+    return shared_marginal(marginal, func, groups, **args)
 
 
-def shared_histogram(**args: Any) -> DeephavenFigure:
+def shared_histogram(marginal=True, **args: Any) -> DeephavenFigure:
     """
+    Create a histogram figure
 
     Args:
+        marginal: bool:  (Default value = True) Whether this is a marginal figure or not
         **args: Any: The args used for the figure
 
     Returns:
         The DeephavenFigure created
     """
     set_shared_defaults(args)
-    args["barmode"] = args.get("barmode", "relative")
-    args["nbins"] = args.get("nbins", 10)
-    args["histfunc"] = args.get("histfunc", "count")
-    args["histnorm"] = args.get("histnorm", None)
-    args["cumulative"] = args.get("cumulative", False)
-    args["range_bins"] = args.get("range_bins", None)
-    args["barnorm"] = args.get("barnorm", None)
+    set_all(args, HISTOGRAM_DEFAULTS)
 
     args["bargap"] = 0
     args["hist_val_name"] = args["histfunc"]
 
-    return process_args(
-        args, {"bar", "preprocess_hist", "supports_lists"}, px_func=px.bar
-    )
+    func = px.histogram
+    groups = {"bar", "preprocess_hist", "supports_lists"}
+
+    return shared_marginal(marginal, func, groups, **args)
 
 
 def marginal_axis_update(matches: str = None) -> dict[str, Any]:
@@ -390,11 +515,11 @@ def create_marginal(marginal: str, args: dict[str, Any], which: str) -> Deephave
     }
 
     fig_marg = marginal_map[marginal](**args)
-    fig_marg.fig.update_traces(showlegend=False)
+    fig_marg.get_plotly_fig().update_traces(showlegend=False)
 
     if marginal == "rug":
         symbol = "line-ns-open" if which == "x" else "line-ew-open"
-        fig_marg.fig.update_traces(marker_symbol=symbol, jitter=0)
+        fig_marg.get_plotly_fig().update_traces(marker_symbol=symbol, jitter=0)
 
     return fig_marg
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/area.py b/plugins/plotly-express/src/deephaven/plot/express/plots/area.py
index c6e5bac0f..084fab2d3 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/area.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/area.py
@@ -5,7 +5,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/bar.py b/plugins/plotly-express/src/deephaven/plot/express/plots/bar.py
index 17c999483..c013fd6c0 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/bar.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/bar.py
@@ -7,7 +7,7 @@
 from deephaven.table import Table
 
 from ._private_utils import validate_common_args, process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import generate_figure, DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/distribution.py b/plugins/plotly-express/src/deephaven/plot/express/plots/distribution.py
index 1d42e0cb0..65cec7c54 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/distribution.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/distribution.py
@@ -14,32 +14,40 @@
     shared_strip,
     shared_histogram,
 )
-from ._update_wrapper import default_callback, unsafe_figure_update_wrapper
 from ..deephaven_figure import DeephavenFigure
 from ..preprocess import preprocess_ecdf
 
+from ..shared import (
+    VIOLIN_DEFAULTS,
+    BOX_DEFAULTS,
+    STRIP_DEFAULTS,
+    HISTOGRAM_DEFAULTS,
+    default_callback,
+    unsafe_figure_update_wrapper,
+)
+
 
 def violin(
     table: Table = None,
     x: str | list[str] = None,
     y: str | list[str] = None,
     by: str | list[str] = None,
-    by_vars: str | list[str] = "color",
+    by_vars: str | list[str] = VIOLIN_DEFAULTS["by_vars"],
     color: str | list[str] = None,
     hover_name: str = None,
     labels: dict[str, str] = None,
     color_discrete_sequence: list[str] = None,
     color_discrete_map: dict[str | tuple[str], str] = None,
-    violinmode: str = "group",
+    violinmode: str = VIOLIN_DEFAULTS["violinmode"],
     log_x: bool = False,
     log_y: bool = False,
     range_x: list[int] = None,
     range_y: list[int] = None,
-    points: bool | str = "outliers",
+    points: bool | str = VIOLIN_DEFAULTS["points"],
     box: bool = False,
     title: str = None,
     template: str = None,
-    unsafe_update_figure: callable = default_callback,
+    unsafe_update_figure: callable = VIOLIN_DEFAULTS["unsafe_update_figure"],
 ) -> DeephavenFigure:
     """Returns a violin chart
 
@@ -120,7 +128,7 @@ def violin(
 
     args = locals()
 
-    return shared_violin(**args)
+    return shared_violin(marginal=False, **args)
 
 
 def box(
@@ -128,22 +136,22 @@ def box(
     x: str | list[str] = None,
     y: str | list[str] = None,
     by: str | list[str] = None,
-    by_vars: str | list[str] = "color",
+    by_vars: str | list[str] = BOX_DEFAULTS["by_vars"],
     color: str | list[str] = None,
     hover_name: str = None,
     labels: dict[str, str] = None,
     color_discrete_sequence: list[str] = None,
     color_discrete_map: dict[str | tuple[str], str] = None,
-    boxmode: str = "group",
+    boxmode: str = BOX_DEFAULTS["boxmode"],
     log_x: bool = False,
     log_y: bool = False,
     range_x: list[int] = None,
     range_y: list[int] = None,
-    points: bool | str = "outliers",
+    points: bool | str = BOX_DEFAULTS["points"],
     notched: bool = False,
     title: str = None,
     template: str = None,
-    unsafe_update_figure: Callable = default_callback,
+    unsafe_update_figure: Callable = BOX_DEFAULTS["unsafe_update_figure"],
 ) -> DeephavenFigure:
     """Returns a box chart
 
@@ -224,7 +232,7 @@ def box(
 
     args = locals()
 
-    return shared_box(**args)
+    return shared_box(marginal=False, **args)
 
 
 def strip(
@@ -232,20 +240,20 @@ def strip(
     x: str | list[str] = None,
     y: str | list[str] = None,
     by: str | list[str] = None,
-    by_vars: str | list[str] = "color",
+    by_vars: str | list[str] = STRIP_DEFAULTS["by_vars"],
     color: str | list[str] = None,
     hover_name: str = None,
     labels: dict[str, str] = None,
     color_discrete_sequence: list[str] = None,
     color_discrete_map: dict[str | tuple[str], str] = None,
-    stripmode: bool | str = "group",
+    stripmode: bool | str = STRIP_DEFAULTS["stripmode"],
     log_x: bool = False,
     log_y: bool = False,
     range_x: list[int] = None,
     range_y: list[int] = None,
     title: str = None,
     template: str = None,
-    unsafe_update_figure: Callable = default_callback,
+    unsafe_update_figure: Callable = STRIP_DEFAULTS["unsafe_update_figure"],
 ) -> DeephavenFigure:
     """Returns a strip chart
 
@@ -320,7 +328,7 @@ def strip(
 
     args = locals()
 
-    return shared_strip(**args)
+    return shared_strip(marginal=False, **args)
 
 
 def _ecdf(
@@ -398,7 +406,7 @@ def histogram(
     x: str | list[str] = None,
     y: str | list[str] = None,
     by: str | list[str] = None,
-    by_vars: str | list[str] = "color",
+    by_vars: str | list[str] = HISTOGRAM_DEFAULTS["by_vars"],
     color: str | list[str] = None,
     pattern_shape: str | list[str] = None,
     labels: dict[str, str] = None,
@@ -408,17 +416,17 @@ def histogram(
     pattern_shape_map: dict[str | tuple[str], str] = None,
     marginal: str = None,
     opacity: float = None,
-    barmode: str = "relative",
-    barnorm: str = None,
-    histnorm: str = None,
+    barmode: str = HISTOGRAM_DEFAULTS["barmode"],
+    barnorm: str = HISTOGRAM_DEFAULTS["barnorm"],
+    histnorm: str = HISTOGRAM_DEFAULTS["histnorm"],
     log_x: bool = False,
     log_y: bool = False,
     range_x: list[int] = None,
     range_y: list[int] = None,
-    range_bins: list[int] = None,
-    histfunc: str = "count",
-    cumulative: bool = False,
-    nbins: int = 10,
+    range_bins: list[int] = HISTOGRAM_DEFAULTS["range_bins"],
+    histfunc: str = HISTOGRAM_DEFAULTS["histfunc"],
+    cumulative: bool = HISTOGRAM_DEFAULTS["cumulative"],
+    nbins: int = HISTOGRAM_DEFAULTS["nbins"],
     text_auto: bool | str = False,
     title: str = None,
     template: str = None,
@@ -532,4 +540,4 @@ def histogram(
     """
     args = locals()
 
-    return shared_histogram(**args)
+    return shared_histogram(marginal=False, **args)
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/financial.py b/plugins/plotly-express/src/deephaven/plot/express/plots/financial.py
index b636ea02b..bbaec5ae8 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/financial.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/financial.py
@@ -3,7 +3,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import draw_ohlc, draw_candlestick, DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/hierarchial.py b/plugins/plotly-express/src/deephaven/plot/express/plots/hierarchial.py
index 349e0ec76..ceecb14d5 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/hierarchial.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/hierarchial.py
@@ -7,7 +7,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/line.py b/plugins/plotly-express/src/deephaven/plot/express/plots/line.py
index 9a431f258..39c65612b 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/line.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/line.py
@@ -5,7 +5,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py b/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py
index 17b1731ce..f789e8597 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py
@@ -7,7 +7,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 
 
 def scatter_geo(
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/pie.py b/plugins/plotly-express/src/deephaven/plot/express/plots/pie.py
index 61631e94b..5815ef08d 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/pie.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/pie.py
@@ -5,7 +5,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/scatter.py b/plugins/plotly-express/src/deephaven/plot/express/plots/scatter.py
index e9a245486..f65393097 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/scatter.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/plots/scatter.py
@@ -7,7 +7,7 @@
 from deephaven.table import Table
 
 from ._private_utils import process_args
-from ._update_wrapper import default_callback
+from ..shared import default_callback
 from ..deephaven_figure import DeephavenFigure
 
 
diff --git a/plugins/plotly-express/src/deephaven/plot/express/shared/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/shared/__init__.py
index a97611d07..42bc5820e 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/shared/__init__.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/shared/__init__.py
@@ -1,1 +1,9 @@
-from .shared import combined_generator, get_unique_names
+from .shared import combined_generator, get_unique_names, args_copy
+from ._update_wrapper import default_callback, unsafe_figure_update_wrapper
+from .distribution_args import (
+    SHARED_DEFAULTS,
+    VIOLIN_DEFAULTS,
+    BOX_DEFAULTS,
+    STRIP_DEFAULTS,
+    HISTOGRAM_DEFAULTS,
+)
diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_update_wrapper.py b/plugins/plotly-express/src/deephaven/plot/express/shared/_update_wrapper.py
similarity index 86%
rename from plugins/plotly-express/src/deephaven/plot/express/plots/_update_wrapper.py
rename to plugins/plotly-express/src/deephaven/plot/express/shared/_update_wrapper.py
index 58d4db12f..eb4e3ec84 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/plots/_update_wrapper.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/shared/_update_wrapper.py
@@ -32,6 +32,6 @@ def unsafe_figure_update_wrapper(
 
     """
     # allow either returning a new fig or not from callback
-    new_fig = unsafe_figure_update(dh_fig.fig)
-    dh_fig.fig = new_fig if new_fig else dh_fig.fig
+    new_fig = unsafe_figure_update(dh_fig.get_plotly_fig())
+    dh_fig._plotly_fig = new_fig if new_fig else dh_fig.get_plotly_fig()
     return dh_fig
diff --git a/plugins/plotly-express/src/deephaven/plot/express/shared/distribution_args.py b/plugins/plotly-express/src/deephaven/plot/express/shared/distribution_args.py
new file mode 100644
index 000000000..057b7459a
--- /dev/null
+++ b/plugins/plotly-express/src/deephaven/plot/express/shared/distribution_args.py
@@ -0,0 +1,42 @@
+from ._update_wrapper import default_callback
+
+# This file contains the default arguments for the distribution plots, which
+# have multiple entry points because they can be created from either a
+# marginal or a user-created plot.
+
+SHARED_DEFAULTS = {
+    "by_vars": "color",
+    "unsafe_update_figure": default_callback,
+    "x": None,
+    "y": None,
+}
+
+VIOLIN_DEFAULTS = {
+    "violinmode": "group",
+    "points": "outliers",
+    **SHARED_DEFAULTS,
+}
+
+BOX_DEFAULTS = {
+    "boxmode": "group",
+    "points": "outliers",
+    **SHARED_DEFAULTS,
+}
+
+STRIP_DEFAULTS = {
+    "stripmode": "group",
+    **SHARED_DEFAULTS,
+}
+
+HISTOGRAM_DEFAULTS = {
+    "barmode": "group",
+    "nbins": 10,
+    "histfunc": "count",
+    "histnorm": None,
+    "cumulative": False,
+    "range_bins": None,
+    "barnorm": None,
+    **SHARED_DEFAULTS,
+}
+
+SPREAD_GROUPS = {"marker", "preprocess_spread", "supports_lists"}
diff --git a/plugins/plotly-express/src/deephaven/plot/express/shared/shared.py b/plugins/plotly-express/src/deephaven/plot/express/shared/shared.py
index e31a77fc9..ef29461b9 100644
--- a/plugins/plotly-express/src/deephaven/plot/express/shared/shared.py
+++ b/plugins/plotly-express/src/deephaven/plot/express/shared/shared.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
+import copy
 from collections.abc import Generator
+from typing import Any
 
 from deephaven.table import Table, PartitionedTable
 
@@ -60,3 +62,20 @@ def get_unique_names(table: Table, orig_names: list[str]) -> dict[str, str]:
             new_name = "_" + new_name
         new_names[name] = new_name
     return new_names
+
+
+def args_copy(args: dict[str, Any]) -> dict[str, Any]:
+    """Copy the args dictionary, copying any dictionaries one layer deep
+    but not deeper so the table is not copied.
+
+    Args:
+        args: dict[dict[str: Any]]: The args dictionary to copy
+
+    Returns:
+        dict[dict[str, Any]]: The copied args dictionary
+
+    """
+    copy_dict = {}
+    for k, v in args.items():
+        copy_dict[k] = copy.copy(v) if isinstance(v, dict) else v
+    return copy_dict
diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts
index 4b5af57a8..fd07f7557 100644
--- a/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts
+++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartModel.ts
@@ -2,179 +2,322 @@ import type { Layout, Data } from 'plotly.js';
 import type {
   dh as DhType,
   ChartData,
+  JsWidget,
   Table,
   TableSubscription,
+  TableData,
 } from '@deephaven/jsapi-types';
 import { ChartModel, ChartUtils, ChartTheme } from '@deephaven/chart';
 import Log from '@deephaven/log';
-import { applyColorwayToData } from './PlotlyExpressChartUtils.js';
+import {
+  PlotlyChartWidgetData,
+  applyColorwayToData,
+  getDataMappings,
+  getWidgetData,
+} from './PlotlyExpressChartUtils.js';
 
 const log = Log.module('@deephaven/js-plugin-plotly-express.ChartModel');
 
 export class PlotlyExpressChartModel extends ChartModel {
   constructor(
     dh: DhType,
-    tableColumnReplacementMap: ReadonlyMap<Table, Map<string, string[]>>,
-    data: Data[],
-    plotlyLayout: Partial<Layout>,
-    isDefaultTemplate = true,
+    widget: JsWidget,
+    refetch: () => Promise<JsWidget>,
     theme: typeof ChartTheme = ChartTheme
   ) {
     super(dh);
 
+    this.widget = widget;
+    this.refetch = refetch;
+    this.chartUtils = new ChartUtils(dh);
+
     this.handleFigureUpdated = this.handleFigureUpdated.bind(this);
+    this.handleWidgetUpdated = this.handleWidgetUpdated.bind(this);
 
-    this.chartUtils = new ChartUtils(dh);
-    this.tableColumnReplacementMap = new Map(tableColumnReplacementMap);
-    this.chartDataMap = new Map();
-    this.tableSubscriptionMap = new Map();
+    // This is mostly used for setting the initial layout.
+    // Chart only fetches the model layout once on init, so it needs to be set
+    // before the widget is subscribed to.
+    this.handleWidgetUpdated(getWidgetData(widget), widget.exportedObjects);
 
     this.theme = theme;
-    this.data = data;
-    const template = { layout: this.chartUtils.makeDefaultLayout(theme) };
+    this.setTitle(this.getDefaultTitle());
+  }
 
-    // For now we will only use the plotly theme colorway since most plotly themes are light mode
-    if (!isDefaultTemplate) {
-      template.layout.colorway =
-        plotlyLayout.template?.layout?.colorway ?? template.layout.colorway;
-    }
+  isSubscribed = false;
 
-    this.plotlyLayout = plotlyLayout;
+  chartUtils: ChartUtils;
 
-    this.layout = {
-      ...plotlyLayout,
-      template,
-    };
+  refetch: () => Promise<JsWidget>;
 
-    applyColorwayToData(
-      this.layout?.template?.layout?.colorway ?? [],
-      this.plotlyLayout?.template?.layout?.colorway ?? [],
-      this.data
-    );
+  widget?: JsWidget;
 
-    this.setTitle(this.getDefaultTitle());
-  }
+  widgetUnsubscribe?: () => void;
 
-  chartUtils: ChartUtils;
+  /**
+   * Map of table index to Table object.
+   */
+  tableReferenceMap: Map<number, Table> = new Map();
 
-  tableSubscriptionMap: Map<Table, TableSubscription>;
+  /**
+   * Map of table index to TableSubscription object.
+   */
+  tableSubscriptionMap: Map<number, TableSubscription> = new Map();
 
-  tableSubscriptionCleanups: (() => void)[] = [];
+  /**
+   * Map of table index to cleanup function for the subscription.
+   */
+  subscriptionCleanupMap: Map<number, () => void> = new Map();
 
-  tableColumnReplacementMap: Map<Table, Map<string, string[]>>;
+  /**
+   * Map of table index to map of column names to array of paths where the data should be replaced.
+   */
+  tableColumnReplacementMap: Map<number, Map<string, string[]>> = new Map();
 
-  chartDataMap: Map<Table, ChartData>;
+  /**
+   * Map of table index to ChartData object. Used to handle data delta updates.
+   */
+  chartDataMap: Map<number, ChartData> = new Map();
+
+  /**
+   * Map of table index to object where the keys are column names and the values are arrays of data.
+   * This data is the full array of data for the column since ChartData doesn't have a clean way to get it at any time.
+   */
+  tableDataMap: Map<number, { [key: string]: unknown[] }> = new Map();
 
   theme: typeof ChartTheme;
 
-  data: Data[];
+  plotlyData: Data[] = [];
 
-  layout: Partial<Layout>;
+  layout: Partial<Layout> = {};
 
-  plotlyLayout: Partial<Layout>;
+  plotlyLayout: Partial<Layout> = {};
 
   isPaused = false;
 
   hasPendingUpdate = false;
 
   override getData(): Partial<Data>[] {
-    return this.data;
+    const hydratedData = [...this.plotlyData];
+
+    this.tableColumnReplacementMap.forEach((columnReplacements, tableId) => {
+      const tableData = this.tableDataMap.get(tableId);
+      if (tableData == null) {
+        throw new Error(`No tableData for table ID ${tableId}`);
+      }
+
+      // Replace placeholder arrays with actual data
+      columnReplacements.forEach((paths, columnName) => {
+        paths.forEach(destination => {
+          // The JSON pointer starts w/ /plotly/data and we don't need that part
+          const parts = destination
+            .split('/')
+            .filter(
+              part => part !== '' && part !== 'plotly' && part !== 'data'
+            );
+          // eslint-disable-next-line @typescript-eslint/no-explicit-any
+          let selector: any = hydratedData;
+          for (let i = 0; i < parts.length; i += 1) {
+            if (i !== parts.length - 1) {
+              selector = selector[parts[i]];
+            } else {
+              selector[parts[i]] = tableData[columnName] ?? [];
+            }
+          }
+        });
+      });
+    });
+
+    return hydratedData;
   }
 
-  getLayout(): Partial<Layout> {
+  override getLayout(): Partial<Layout> {
     return this.layout;
   }
 
-  subscribe(callback: (event: CustomEvent) => void): void {
-    super.subscribe(callback);
+  override close() {
+    super.close();
+    this.widget?.close();
+    this.widget = undefined;
+  }
 
-    const { dh } = this;
+  override async subscribe(
+    callback: (event: CustomEvent) => void
+  ): Promise<void> {
+    if (this.isSubscribed) {
+      return;
+    }
+    super.subscribe(callback);
+    if (this.widget == null) {
+      this.widget = await this.refetch();
+      const widgetData = getWidgetData(this.widget);
+      this.handleWidgetUpdated(widgetData, this.widget.exportedObjects);
+    }
 
-    this.tableColumnReplacementMap.forEach((_, table) =>
-      this.chartDataMap.set(table, new dh.plot.ChartData(table))
+    this.isSubscribed = true;
+    this.widgetUnsubscribe = this.widget.addEventListener(
+      this.dh.Widget.EVENT_MESSAGE,
+      ({ detail }) => {
+        this.handleWidgetUpdated(
+          JSON.parse(detail.getDataAsString()),
+          detail.exportedObjects
+        );
+      }
     );
 
-    this.tableColumnReplacementMap.forEach((columnReplacements, table) => {
-      const columnNames = new Set(columnReplacements.keys());
-      const columns = table.columns.filter(({ name }) => columnNames.has(name));
-      this.tableSubscriptionMap.set(table, table.subscribe(columns));
-    });
+    this.tableReferenceMap.forEach((_, id) => this.subscribeTable(id));
 
-    this.startListening();
+    // If there are no tables to fetch data from, the chart is ready to render
+    // Normally this event only fires once at least 1 table has fetched data
+    // Without this, the chart shows an infinite loader if there are no tables
+    if (this.tableColumnReplacementMap.size === 0) {
+      this.fireUpdate(this.getData());
+    }
   }
 
-  unsubscribe(callback: (event: CustomEvent) => void): void {
+  override unsubscribe(callback: (event: CustomEvent) => void): void {
+    if (!this.isSubscribed) {
+      return;
+    }
     super.unsubscribe(callback);
+    this.widgetUnsubscribe?.();
+    this.isSubscribed = false;
+
+    this.tableReferenceMap.forEach((_, id) => this.removeTable(id));
+
+    this.widget?.close();
+    this.widget = undefined;
+  }
+
+  updateLayout(data: PlotlyChartWidgetData) {
+    const { figure } = data;
+    const { plotly, deephaven } = figure;
+    const { layout: plotlyLayout = {} } = plotly;
 
-    this.stopListening();
+    const template = { layout: this.chartUtils.makeDefaultLayout(this.theme) };
 
-    this.tableSubscriptionMap.forEach(sub => sub.close());
-    this.chartDataMap.clear();
+    // For now we will only use the plotly theme colorway since most plotly themes are light mode
+    if (deephaven.is_user_set_template) {
+      template.layout.colorway =
+        plotlyLayout.template?.layout?.colorway ?? template.layout.colorway;
+    }
+
+    this.plotlyLayout = plotlyLayout;
+
+    this.layout = {
+      ...plotlyLayout,
+      template,
+    };
   }
 
-  handleFigureUpdated(
-    event: CustomEvent,
-    chartData: ChartData | undefined,
-    columnReplacements: Map<string, string[]> | undefined
+  handleWidgetUpdated(
+    data: PlotlyChartWidgetData,
+    references: JsWidget['exportedObjects']
   ): void {
-    if (chartData == null || columnReplacements == null) {
-      log.warn(
-        'Unknown chartData or columnReplacements for this event. Skipping update'
-      );
+    const {
+      figure,
+      new_references: newReferences,
+      removed_references: removedReferences,
+    } = data;
+    const { plotly } = figure;
+    this.tableColumnReplacementMap = getDataMappings(data);
+
+    this.plotlyData = plotly.data;
+    this.updateLayout(data);
+
+    applyColorwayToData(
+      this.layout?.template?.layout?.colorway ?? [],
+      this.plotlyLayout?.template?.layout?.colorway ?? [],
+      this.plotlyData
+    );
+
+    newReferences.forEach(async (id, i) => {
+      this.tableDataMap.set(id, {}); // Plot may render while tables are being fetched. Set this to avoid a render error
+      const table = (await references[i].fetch()) as Table;
+      this.addTable(id, table);
+    });
+
+    removedReferences.forEach(id => this.removeTable(id));
+  }
+
+  handleFigureUpdated(event: CustomEvent<TableData>, tableId: number): void {
+    const chartData = this.chartDataMap.get(tableId);
+    const tableData = this.tableDataMap.get(tableId);
+
+    if (chartData == null) {
+      log.warn('Unknown chartData for this event. Skipping update');
+      return;
+    }
+
+    if (tableData == null) {
+      log.warn('No tableData for this event. Skipping update');
       return;
     }
+
     const { detail: figureUpdateEvent } = event;
     chartData.update(figureUpdateEvent);
-
-    columnReplacements.forEach((destinations, column) => {
+    figureUpdateEvent.columns.forEach(column => {
       const columnData = chartData.getColumn(
-        column,
+        column.name,
         val => this.chartUtils.unwrapValue(val),
         figureUpdateEvent
       );
-      destinations.forEach(destination => {
-        // The JSON pointer starts w/ /plotly and we don't need that part
-        const parts = destination
-          .split('/')
-          .filter(part => part !== '' && part !== 'plotly');
-        // eslint-disable-next-line @typescript-eslint/no-this-alias, @typescript-eslint/no-explicit-any
-        let selector: any = this;
-        for (let i = 0; i < parts.length; i += 1) {
-          if (i !== parts.length - 1) {
-            selector = selector[parts[i]];
-          } else {
-            selector[parts[i]] = columnData;
-          }
-        }
-      });
+      tableData[column.name] = columnData;
     });
 
-    const { data } = this;
-
     if (this.isPaused) {
       this.hasPendingUpdate = true;
       return;
     }
 
-    this.fireUpdate(data);
+    this.fireUpdate(this.getData());
   }
 
-  startListening(): void {
-    this.tableSubscriptionMap.forEach((sub, table) => {
-      this.tableSubscriptionCleanups.push(
-        sub.addEventListener(this.dh.Table.EVENT_UPDATED, e =>
-          this.handleFigureUpdated(
-            e,
-            this.chartDataMap.get(table),
-            this.tableColumnReplacementMap.get(table)
-          )
+  addTable(id: number, table: Table) {
+    if (this.tableReferenceMap.has(id)) {
+      return;
+    }
+    this.tableReferenceMap.set(id, table);
+    this.tableDataMap.set(id, {});
+
+    if (this.isSubscribed) {
+      this.subscribeTable(id);
+    }
+  }
+
+  subscribeTable(id: number) {
+    const table = this.tableReferenceMap.get(id);
+    const columnReplacements = this.tableColumnReplacementMap.get(id);
+
+    if (
+      table != null &&
+      columnReplacements != null &&
+      columnReplacements.size > 0 &&
+      !this.tableSubscriptionMap.has(id)
+    ) {
+      this.chartDataMap.set(id, new this.dh.plot.ChartData(table));
+      const columnNames = new Set(columnReplacements.keys());
+      const columns = table.columns.filter(({ name }) => columnNames.has(name));
+      const subscription = table.subscribe(columns);
+      this.tableSubscriptionMap.set(id, subscription);
+      this.subscriptionCleanupMap.set(
+        id,
+        subscription.addEventListener(this.dh.Table.EVENT_UPDATED, e =>
+          this.handleFigureUpdated(e, id)
         )
       );
-    });
+    }
   }
 
-  stopListening(): void {
-    this.tableSubscriptionCleanups.forEach(cleanup => cleanup());
+  removeTable(id: number) {
+    this.subscriptionCleanupMap.get(id)?.();
+    this.tableSubscriptionMap.get(id)?.close();
+
+    this.tableReferenceMap.delete(id);
+    this.subscriptionCleanupMap.delete(id);
+    this.tableSubscriptionMap.delete(id);
+    this.chartDataMap.delete(id);
+    this.tableDataMap.delete(id);
+    this.tableColumnReplacementMap.delete(id);
   }
 
   override fireUpdate(data: unknown): void {
@@ -189,7 +332,7 @@ export class PlotlyExpressChartModel extends ChartModel {
   resumeUpdates(): void {
     this.isPaused = false;
     if (this.hasPendingUpdate) {
-      this.fireUpdate(this.data);
+      this.fireUpdate(this.getData());
     }
   }
 
@@ -200,19 +343,19 @@ export class PlotlyExpressChartModel extends ChartModel {
   }
 
   hasScene(): boolean {
-    return this.data.some(d => 'scene' in d && d.scene != null);
+    return this.plotlyData.some(d => 'scene' in d && d.scene != null);
   }
 
   hasGeo(): boolean {
-    return this.data.some(d => 'geo' in d && d.geo != null);
+    return this.plotlyData.some(d => 'geo' in d && d.geo != null);
   }
 
   hasMapbox(): boolean {
-    return this.data.some(({ type }) => type?.includes('mapbox'));
+    return this.plotlyData.some(({ type }) => type?.includes('mapbox'));
   }
 
   hasPolar(): boolean {
-    return this.data.some(({ type }) => type?.includes('polar'));
+    return this.plotlyData.some(({ type }) => type?.includes('polar'));
   }
 
   getPlotWidth(): number {
diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartPanel.tsx b/plugins/plotly-express/src/js/src/PlotlyExpressChartPanel.tsx
index d140cf773..55e4ce300 100644
--- a/plugins/plotly-express/src/js/src/PlotlyExpressChartPanel.tsx
+++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartPanel.tsx
@@ -1,11 +1,9 @@
 import React, { useCallback, useEffect, useRef, useState } from 'react';
 import Plotly from 'plotly.js-dist-min';
 import { ChartPanel, ChartPanelProps } from '@deephaven/dashboard-core-plugins';
-import { ChartTheme } from '@deephaven/chart';
 import { type WidgetComponentProps } from '@deephaven/plugin';
 import { useApi } from '@deephaven/jsapi-bootstrap';
 import PlotlyExpressChartModel from './PlotlyExpressChartModel.js';
-import { getWidgetData, getDataMappings } from './PlotlyExpressChartUtils.js';
 
 function PlotlyExpressChartPanel(props: WidgetComponentProps) {
   const dh = useApi();
@@ -14,19 +12,8 @@ function PlotlyExpressChartPanel(props: WidgetComponentProps) {
   const [model, setModel] = useState<PlotlyExpressChartModel>();
 
   const makeModel = useCallback(async () => {
-    const widgetInfo = await fetch();
-    const data = getWidgetData(widgetInfo);
-    const { plotly, deephaven } = data;
-    const isDefaultTemplate = !deephaven.is_user_set_template;
-    const tableColumnReplacementMap = await getDataMappings(widgetInfo);
-    const m = new PlotlyExpressChartModel(
-      dh,
-      tableColumnReplacementMap,
-      plotly.data,
-      plotly.layout ?? {},
-      isDefaultTemplate,
-      ChartTheme
-    );
+    const widgetData = await fetch();
+    const m = new PlotlyExpressChartModel(dh, widgetData, fetch);
     setModel(m);
     return m;
   }, [dh, fetch]);
@@ -85,7 +72,7 @@ function PlotlyExpressChartPanel(props: WidgetComponentProps) {
       containerRef={containerRef}
       makeModel={makeModel}
       Plotly={Plotly}
-      metadata={metadata}
+      metadata={metadata as ChartPanelProps['metadata']}
     />
   );
 }
diff --git a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts
index d454a8d3b..5b534a4a1 100644
--- a/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts
+++ b/plugins/plotly-express/src/js/src/PlotlyExpressChartUtils.ts
@@ -1,43 +1,45 @@
 import type { Data, PlotlyDataLayoutConfig } from 'plotly.js';
-import type { JsWidget, Table } from '@deephaven/jsapi-types';
-import { assertNotNull } from '@deephaven/utils';
+import type { JsWidget } from '@deephaven/jsapi-types';
 import Log from '@deephaven/log';
 
 const log = Log.module('@deephaven/js-plugin-plotly-express.ChartUtils');
 
 export interface PlotlyChartWidgetData {
-  deephaven: {
-    mappings: Array<{
-      table: number;
-      data_columns: Record<string, string[]>;
-    }>;
-    is_user_set_template: boolean;
-    is_user_set_color: boolean;
+  type: string;
+  figure: {
+    deephaven: {
+      mappings: Array<{
+        table: number;
+        data_columns: Record<string, string[]>;
+      }>;
+      is_user_set_template: boolean;
+      is_user_set_color: boolean;
+    };
+    plotly: PlotlyDataLayoutConfig;
   };
-  plotly: PlotlyDataLayoutConfig;
+  revision: number;
+  new_references: number[];
+  removed_references: number[];
 }
 
 export function getWidgetData(widgetInfo: JsWidget): PlotlyChartWidgetData {
   return JSON.parse(widgetInfo.getDataAsString());
 }
 
-export async function getDataMappings(
-  widgetInfo: JsWidget
-): Promise<Map<Table, Map<string, string[]>>> {
-  const data = getWidgetData(widgetInfo);
-  const tables = await Promise.all(
-    widgetInfo.exportedObjects.map(obj => obj.fetch() as Promise<Table>)
-  );
+export function getDataMappings(
+  widgetData: PlotlyChartWidgetData
+): Map<number, Map<string, string[]>> {
+  const data = widgetData.figure;
 
-  // Maps a table to a map of column name to an array of the paths where its data should be
-  const tableColumnReplacementMap = new Map<Table, Map<string, string[]>>();
-  tables.forEach(table => tableColumnReplacementMap.set(table, new Map()));
+  // Maps a reference index to a map of column name to an array of the paths where its data should be
+  const tableColumnReplacementMap = new Map<number, Map<string, string[]>>();
 
   data.deephaven.mappings.forEach(
     ({ table: tableIndex, data_columns: dataColumns }) => {
-      const table = tables[tableIndex];
-      const existingColumnMap = tableColumnReplacementMap.get(table);
-      assertNotNull(existingColumnMap);
+      const existingColumnMap =
+        tableColumnReplacementMap.get(tableIndex) ??
+        new Map<string, string[]>();
+      tableColumnReplacementMap.set(tableIndex, existingColumnMap);
 
       // For each { columnName: [replacePaths] } in the object, add to the tableColumnReplacementMap
       Object.entries(dataColumns).forEach(([columnName, paths]) => {
diff --git a/plugins/plotly-express/tox.ini b/plugins/plotly-express/tox.ini
index b214f738a..1c73b2911 100644
--- a/plugins/plotly-express/tox.ini
+++ b/plugins/plotly-express/tox.ini
@@ -3,6 +3,6 @@ isolated_build = True
 
 [testenv]
 deps =
-    deephaven-server
+    deephaven-server>=0.29.0
 commands =
     python -m unittest discover
\ No newline at end of file


Test patch:
diff --git a/plugins/plotly-express/test/deephaven/plot/express/BaseTest.py b/plugins/plotly-express/test/deephaven/plot/express/BaseTest.py
index f37653cea..d043c39c0 100644
--- a/plugins/plotly-express/test/deephaven/plot/express/BaseTest.py
+++ b/plugins/plotly-express/test/deephaven/plot/express/BaseTest.py
@@ -25,7 +25,7 @@ def setup_exporter_mock(cls, MockExporter, MockReference):
         cls.exporter = MockExporter()
         cls.reference = MockReference()
 
-        cls.reference.index = 0
+        cls.reference.id = 0
         cls.exporter.reference.return_value = MockReference()
 
 
diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_area.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_area.py
index 4e82deca8..07dcb17ad 100644
--- a/plugins/plotly-express/test/deephaven/plot/express/plots/test_area.py
+++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_area.py
@@ -83,6 +83,71 @@ def test_basic_area(self):
         self.assertEqual(deephaven["is_user_set_template"], False)
         self.assertEqual(deephaven["is_user_set_color"], False)
 
+    def test_area_step(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.area(self.source, x="X", y="Y", line_shape="hvh").to_dict(
+            self.exporter
+        )
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "fillpattern": {"shape": ""},
+                "hovertemplate": "X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "",
+                "line": {"color": "#636efa", "shape": "hvh"},
+                "marker": {"symbol": "circle"},
+                "mode": "lines",
+                "name": "",
+                "orientation": "v",
+                "showlegend": False,
+                "stackgroup": "1",
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scatter",
+            }
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "title": {"text": "X"},
+                "side": "bottom",
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "title": {"text": "Y"},
+                "side": "left",
+            },
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            }
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], False)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_layer.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_layer.py
new file mode 100644
index 000000000..5db97d830
--- /dev/null
+++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_layer.py
@@ -0,0 +1,201 @@
+import unittest
+
+from ..BaseTest import BaseTestCase
+
+
+class LayerTestCase(BaseTestCase):
+    def setUp(self) -> None:
+        from deephaven import new_table
+        from deephaven.column import int_col
+
+        self.source = new_table(
+            [
+                int_col("X", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("X2", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("Y", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("Y2", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("size", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("text", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("hover_name", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+            ]
+        )
+
+    def test_same_layered(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.area(self.source, x="X", y="Y")
+        layered = dx.layer(chart, chart).to_dict(self.exporter)
+
+        plotly, deephaven = layered["plotly"], layered["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "fillpattern": {"shape": ""},
+                "hovertemplate": "X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "",
+                "line": {"color": "#636efa", "shape": "linear"},
+                "marker": {"symbol": "circle"},
+                "mode": "lines",
+                "name": "",
+                "orientation": "v",
+                "showlegend": False,
+                "stackgroup": "1",
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scatter",
+            },
+            {
+                "fillpattern": {"shape": ""},
+                "hovertemplate": "X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "",
+                "line": {"color": "#636efa", "shape": "linear"},
+                "marker": {"symbol": "circle"},
+                "mode": "lines",
+                "name": "",
+                "orientation": "v",
+                "showlegend": False,
+                "stackgroup": "1",
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scatter",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "title": {"text": "X"},
+                "side": "bottom",
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "title": {"text": "Y"},
+                "side": "left",
+            },
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            },
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/1/x"], "Y": ["/plotly/data/1/y"]},
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], False)
+
+    def test_different_layered(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.area(self.source, x="X", y="Y")
+        chart2 = dx.area(self.source, x="X2", y="Y2")
+        layered = dx.layer(chart, chart2).to_dict(self.exporter)
+
+        plotly, deephaven = layered["plotly"], layered["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "fillpattern": {"shape": ""},
+                "hovertemplate": "X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "",
+                "line": {"color": "#636efa", "shape": "linear"},
+                "marker": {"symbol": "circle"},
+                "mode": "lines",
+                "name": "",
+                "orientation": "v",
+                "showlegend": False,
+                "stackgroup": "1",
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scatter",
+            },
+            {
+                "fillpattern": {"shape": ""},
+                "hovertemplate": "X2=%{x}<br>Y2=%{y}<extra></extra>",
+                "legendgroup": "",
+                "line": {"color": "#636efa", "shape": "linear"},
+                "marker": {"symbol": "circle"},
+                "mode": "lines",
+                "name": "",
+                "orientation": "v",
+                "showlegend": False,
+                "stackgroup": "1",
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scatter",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "title": {"text": "X2"},
+                "side": "bottom",
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "title": {"text": "Y2"},
+                "side": "left",
+            },
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X2": ["/plotly/data/1/x"],
+                    "Y2": ["/plotly/data/1/y"],
+                },
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], False)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_scatter.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_scatter.py
new file mode 100644
index 000000000..3c5eeb44a
--- /dev/null
+++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_scatter.py
@@ -0,0 +1,615 @@
+import unittest
+
+from ..BaseTest import BaseTestCase
+
+
+class ScatterTestCase(BaseTestCase):
+    def setUp(self) -> None:
+        from deephaven import new_table
+        from deephaven.column import int_col
+
+        self.source = new_table(
+            [
+                int_col("X", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("X2", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("Y", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("Y2", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("size", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("text", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("hover_name", [1, 2, 2, 3, 3, 3, 4, 4, 5]),
+                int_col("category", [1, 2, 1, 2, 1, 2, 1, 2, 1]),
+            ]
+        )
+
+    def test_basic_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(self.source, x="X", y="Y").to_dict(self.exporter)
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "",
+                "marker": {"color": "#636efa", "symbol": "circle"},
+                "mode": "markers",
+                "name": "",
+                "showlegend": False,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            }
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "Y"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            }
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], False)
+
+    def test_list_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(self.source, x="X", y=["Y", "Y2"]).to_dict(self.exporter)
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "variable=Y<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "Y",
+                "marker": {"color": "#636EFA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "Y",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "variable=Y2<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "Y2",
+                "marker": {"color": "#EF553B", "symbol": "circle"},
+                "mode": "markers",
+                "name": "Y2",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "value"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/0/x"],
+                    "value": ["/plotly/data/0/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/1/x"],
+                    "value": ["/plotly/data/1/y"],
+                },
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], True)
+
+    def test_by_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(self.source, x="X", y="Y", by="category").to_dict(
+            self.exporter
+        )
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "category=1<br>X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "1",
+                "marker": {"color": "#636EFA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "1",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=2<br>X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "2",
+                "marker": {"color": "#EF553B", "symbol": "circle"},
+                "mode": "markers",
+                "name": "2",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "Y"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            },
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/1/x"], "Y": ["/plotly/data/1/y"]},
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], True)
+
+    def test_by_none_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(
+            self.source, x="X", y="Y", by="category", by_vars=None
+        ).to_dict(self.exporter)
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "category=1<br>X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "1",
+                "marker": {"color": "#636efa", "symbol": "circle"},
+                "mode": "markers",
+                "name": "1",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=2<br>X=%{x}<br>Y=%{y}<extra></extra>",
+                "legendgroup": "2",
+                "marker": {"color": "#636efa", "symbol": "circle"},
+                "mode": "markers",
+                "name": "2",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "Y"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/0/x"], "Y": ["/plotly/data/0/y"]},
+            },
+            {
+                "table": 0,
+                "data_columns": {"X": ["/plotly/data/1/x"], "Y": ["/plotly/data/1/y"]},
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], False)
+
+    def test_list_by_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(self.source, x="X", y=["Y", "Y2"], by="category").to_dict(
+            self.exporter
+        )
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "category=1<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "1",
+                "marker": {"color": "#636EFA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "1",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=2<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "2",
+                "marker": {"color": "#EF553B", "symbol": "circle"},
+                "mode": "markers",
+                "name": "2",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "value"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/0/x"],
+                    "value": ["/plotly/data/0/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/1/x"],
+                    "value": ["/plotly/data/1/y"],
+                },
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], True)
+
+    def test_by_variable_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(self.source, x="X", y=["Y", "Y2"], by="variable").to_dict(
+            self.exporter
+        )
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "variable=Y<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "Y",
+                "marker": {"color": "#636EFA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "Y",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "variable=Y2<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "Y2",
+                "marker": {"color": "#EF553B", "symbol": "circle"},
+                "mode": "markers",
+                "name": "Y2",
+                "showlegend": True,
+                "x": [NULL_INT],
+                "xaxis": "x",
+                "y": [NULL_INT],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "value"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/0/x"],
+                    "value": ["/plotly/data/0/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/1/x"],
+                    "value": ["/plotly/data/1/y"],
+                },
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], True)
+
+    def test_list_by_variable_scatter(self):
+        import src.deephaven.plot.express as dx
+        from deephaven.constants import NULL_INT
+
+        chart = dx.scatter(
+            self.source, x="X", y=["Y", "Y2"], by=["variable", "category"]
+        ).to_dict(self.exporter)
+        plotly, deephaven = chart["plotly"], chart["deephaven"]
+
+        # pop template as we currently do not modify it
+        plotly["layout"].pop("template")
+
+        expected_data = [
+            {
+                "hovertemplate": "category=1<br>variable=Y<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "1, Y",
+                "marker": {"color": "#636EFA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "1, Y",
+                "showlegend": True,
+                "x": [-2147483648],
+                "xaxis": "x",
+                "y": [-2147483648],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=2<br>variable=Y<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "2, Y",
+                "marker": {"color": "#EF553B", "symbol": "circle"},
+                "mode": "markers",
+                "name": "2, Y",
+                "showlegend": True,
+                "x": [-2147483648],
+                "xaxis": "x",
+                "y": [-2147483648],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=1<br>variable=Y2<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "1, Y2",
+                "marker": {"color": "#00CC96", "symbol": "circle"},
+                "mode": "markers",
+                "name": "1, Y2",
+                "showlegend": True,
+                "x": [-2147483648],
+                "xaxis": "x",
+                "y": [-2147483648],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+            {
+                "hovertemplate": "category=2<br>variable=Y2<br>X=%{x}<br>value=%{y}<extra></extra>",
+                "legendgroup": "2, Y2",
+                "marker": {"color": "#AB63FA", "symbol": "circle"},
+                "mode": "markers",
+                "name": "2, Y2",
+                "showlegend": True,
+                "x": [-2147483648],
+                "xaxis": "x",
+                "y": [-2147483648],
+                "yaxis": "y",
+                "type": "scattergl",
+            },
+        ]
+
+        self.assertEqual(plotly["data"], expected_data)
+
+        expected_layout = {
+            "legend": {"tracegroupgap": 0},
+            "margin": {"t": 60},
+            "xaxis": {
+                "anchor": "y",
+                "domain": [0.0, 1.0],
+                "side": "bottom",
+                "title": {"text": "X"},
+            },
+            "yaxis": {
+                "anchor": "x",
+                "domain": [0.0, 1.0],
+                "side": "left",
+                "title": {"text": "value"},
+            },
+        }
+
+        self.assertEqual(plotly["layout"], expected_layout)
+
+        expected_mappings = [
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/0/x"],
+                    "value": ["/plotly/data/0/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/1/x"],
+                    "value": ["/plotly/data/1/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/2/x"],
+                    "value": ["/plotly/data/2/y"],
+                },
+            },
+            {
+                "table": 0,
+                "data_columns": {
+                    "X": ["/plotly/data/3/x"],
+                    "value": ["/plotly/data/3/y"],
+                },
+            },
+        ]
+
+        self.assertEqual(deephaven["mappings"], expected_mappings)
+
+        self.assertEqual(deephaven["is_user_set_template"], False)
+        self.assertEqual(deephaven["is_user_set_color"], True)
+
+
+if __name__ == "__main__":
+    unittest.main()