Repository navigation
feat(export): self-extracting gzip standalone HTML via compress=True - #233
FarhanAliRaza wants to merge 3 commits into
Conversation
At small point counts the inlined render client dominates a standalone export (~304 KB of a ~413 KB file at 10k scatter points). compress=True embeds the client and the data blob as base64 of gzip bytes (pinned mtime, so exports stay deterministic) and inflates them in-browser with DecompressionStream — no bundled inflate code, no server-side compression needed. Measured: 413 KB -> 215 KB at 10k points, with the Chromium screenshot pixel-identical to the plain export (single figure and facet grid). Browsers without DecompressionStream (pre-mid-2023) get a plain-text notice; the default output stays byte-identical. An external shared-client mode (plotly's include_plotlyjs='directory' shape) was evaluated and deliberately not shipped — the reflex adapter already serves the multi-chart economics, and it would widen the CSP. Documented in spec/api/export.md §9 and the §29 static-export row.
Greptile SummaryThis PR adds opt-in gzip compression for standalone HTML exports. The main changes are:
Confidence Score: 5/5Safe to merge with low risk. The change is localized to standalone HTML export paths, keeps No files require special attention.
What T-Rex did
Important Files Changed
Reviews (1): Last reviewed commit: "feat(export): self-extracting gzip stand..." | Re-trigger Greptile |
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
FarhanAliRaza
left a comment
There was a problem hiding this comment.
Changes needed
I validated this head (4e14e2f) by building the core and bundles and exporting a set of charts. I loaded each export over file:// in headless Chromium. The feature works: the compressed and plain screenshots are byte-identical for 1k, 10k and 1M scatter, a line chart with custom_css, a 4-panel facet grid, and an empty chart. Wheel-zoom works on the compressed export. With DecompressionStream removed, the plain-text notice shows. Output is deterministic, and compress="yes" is rejected. The console showed no errors.
Required changes:
- Merge
mainand resolve the conflicts. The branch is 149 commits behindmain.python/xy/export.pyconflicts with the streamedto_htmlparts onmain, andspec/api/export.mdconflicts with the§9already onmain. See the inline comments. - Add a news fragment.
mainnow requires one for changes underpython/(make news NAME=233.feature.md). The CI news check will fail once the branch is merged up. - Get CI green. On this head,
Test (Rust + Python + JS)andPython 3.11 floorboth failed in the "Python tests (native core)" step. Those logs have expired, so re-run CI after the merge and fix what remains. Locally,tests/test_html_export_modes.pypasses (6 tests), and the only full-suite failures also fail on the base commit. - Add a browser test that pins the render and the fallback notice (inline comment on the test file).
- Record the large-N trade-off in spec §9. At 1M points the file is only 10% smaller and the export is 50% slower (inline comment on the spec). This matches the earlier comment that the gain may not justify the feature. With the numbers in the spec, that decision can be made from the data.
The two other inline comments are optional.
Generated by Claude Code
| chunk_scripts = "\n".join( | ||
| f'<script>__xyChunks.push("{c}");</script>' for c in _base64_chunks(blob) | ||
| ) | ||
| wire = _gzip_deterministic(bytes(blob), level=6) if compress else blob |
There was a problem hiding this comment.
Required: this hunk conflicts with main. git merge-tree origin/main 4e14e2f reports content conflicts in python/xy/export.py and spec/api/export.md. On main, to_html now streams parts through _iter_base64_chunks so the document never holds a second full copy of the payload. Merge main, then route the compressed chunks through that same streamed-parts path.
Also drop the bytes(blob) copy here. gzip.compress accepts any bytes-like object, so pass the blob (or a memoryview) directly. Today the compressed path holds the blob, a full bytes copy, and the gzip output at once.
Generated by Claude Code
| files already written stay on disk. The return value is the list of written | ||
| byte strings, in input order. | ||
|
|
||
| ## 9. Standalone HTML delivery (`to_html`) |
There was a problem hiding this comment.
Required: main already has a ## 9. What styling survives which export path in this file, so this heading conflicts. After the merge, renumber this section and update the §9 references (line 36 here, the design-dossier.md §29 row, and the docstrings).
Please also record the large-N result here, so the spec states the trade-off and not only the 10k win. Measured on this head (scatter with a color channel, gzip as shipped):
| points | plain | compress=True | export time plain → gz |
|---|---|---|---|
| 1k | 322 KB | 130 KB | — |
| 10k | 466 KB | 261 KB | 0.018 s → 0.022 s |
| 1M | 16.3 MB | 14.7 MB (−10%) | 0.73 s → 1.10 s (+50%) |
At massive N the f32 data does not compress much, and the export pays the gzip cost.
Generated by Claude Code
| the embedded client and data ride as base64-of-gzip chunks and are inflated | ||
| in-browser via DecompressionStream. These pin the embedded loader, the | ||
| exact-bytes Python-side roundtrip of every compressed chunk, deterministic | ||
| output (pinned gzip mtime), and that the default output is untouched.""" |
There was a problem hiding this comment.
Required: add one browser test. These tests check the Python-side bytes only. Nothing in CI proves that a compress=True document actually inflates and renders, or that the fallback notice shows. Those are the two user-visible claims of this PR. The repo already has conftest.run_browser_probe for this.
Suggested assertions, which all held in my local headless Chromium run:
fig.to_html(compress=True)renders the same canvas/SVG count as the plain export. In my run the screenshots were byte-identical for 1k, 10k and 1M scatter,custom_css, a 4-panel facet grid, and an empty chart.- With
delete window.DecompressionStreamin an init script, the chart div's text equals_COMPRESS_UNSUPPORTED_TEXT, and the page has no canvases.
Generated by Claude Code
| const spec = {spec_js}; | ||
| const buf = await xyInflate(xyDecodeB64(__xyChunks, {len(wire)})); | ||
| __xyChunks.length = 0; | ||
| xy.renderStandalone(el, spec, buf); |
There was a problem hiding this comment.
Optional: if the inflate rejects, the async IIFE has no catch, so the user gets a blank page and an uncaught rejection. Repro: make DecompressionStream return a TransformStream that errors. The page shows an empty #chart, and the console logs pageerror: Failed to fetch. A try/catch that writes a plain-text notice through textContent would match the unsupported-browser path. The same applies to the facet boot in facets.py.
Generated by Claude Code
| (e.g. Tailwind) resolve in the export.""" | ||
| (e.g. Tailwind) resolve in the export. `compress=True` gzips the | ||
| embedded client and data — self-extracting in the browser via | ||
| DecompressionStream, roughly halving the file (export.to_html).""" |
There was a problem hiding this comment.
Optional nit: "roughly halving the file" holds only at small N. At 1M points I measured −10% (16.3 MB → 14.7 MB). Say "at small point counts" here and in components.py:3388, as the spec does.
Also, write_image("x.html") / write_images route to to_html but cannot pass compress. That is consistent with the surface list in the spec, so it is fine as long as it stays intentional.
Generated by Claude Code
Preserve PR behavior while integrating the shared CI build and selection changes.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 41 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
9 issues found across 8 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="spec/api/export.md">
<violation number="1" location="spec/api/export.md:36">
P3: This new cross-reference points `to_html` readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new `compress` contract.</violation>
</file>
<file name="news/233.feature.md">
<violation number="1" location="news/233.feature.md:1">
P3: Mention the public opt-in explicitly so users know how to enable this feature: add `compress=True` to the release note.</violation>
</file>
<file name="python/xy/facets.py">
<violation number="1" location="python/xy/facets.py:222">
P2: The compressed facet loop never releases `p.chunks` after inflation. Clear that array immediately after `await`; otherwise large facet exports retain every compressed base64 chunk while each `ChartView` already retains its decompressed payload, increasing peak memory and risking browser OOM.</violation>
</file>
<file name="tests/test_html_export_modes.py">
<violation number="1" location="tests/test_html_export_modes.py:44">
P2: This test verifies only source-text presence, so a runtime error in `DecompressionStream` handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.</violation>
<violation number="2" location="tests/test_html_export_modes.py:64">
P2: This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.</violation>
</file>
<file name="spec/design-dossier.md">
<violation number="1" location="spec/design-dossier.md:1132">
P3: The `compress=True` contract is documented in `spec/api/export.md` §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.</violation>
<violation number="2" location="spec/design-dossier.md:1132">
P2: The row now documents `compress=True` but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default `1 base64 decode` from compressed `base64 decode + gzip inflate`, and document `gzip + base64` re-encoding for the compressed mode.</violation>
</file>
<file name="python/xy/components.py">
<violation number="1" location="python/xy/components.py:4257">
P3: The public API reference omits the new `compress` parameter from `Chart.to_html()` and `.html()`. Add `compress=False` and document the `DecompressionStream` compatibility behavior.</violation>
</file>
<file name="python/xy/export.py">
<violation number="1" location="python/xy/export.py:243">
P2: `_gzip_deterministic` still emits a platform-dependent gzip OS header on Python 3.11/3.12, so `compress=True` is not byte-deterministic across supported environments. Normalize gzip header byte 9 before embedding.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| for(const p of panels){{ | ||
| const panel=document.createElement("div"); panel.className="xy-facet-panel"; | ||
| const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel); | ||
| const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf); |
There was a problem hiding this comment.
P2: The compressed facet loop never releases p.chunks after inflation. Clear that array immediately after await; otherwise large facet exports retain every compressed base64 chunk while each ChartView already retains its decompressed payload, increasing peak memory and risking browser OOM.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/facets.py, line 222:
<comment>The compressed facet loop never releases `p.chunks` after inflation. Clear that array immediately after `await`; otherwise large facet exports retain every compressed base64 chunk while each `ChartView` already retains its decompressed payload, increasing peak memory and risking browser OOM.</comment>
<file context>
@@ -199,6 +204,36 @@ def to_html(
+ for(const p of panels){{
+ const panel=document.createElement("div"); panel.className="xy-facet-panel";
+ const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel);
+ const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf);
+ }}
+}})();
</file context>
| const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf); | |
| const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); p.chunks.length=0; xy.renderStandalone(host,p.spec,buf); |
|
|
||
| def test_default_output_carries_no_loader_and_pinned_csp() -> None: | ||
| doc = _fig().to_html() | ||
| assert "xyInflate" not in doc |
There was a problem hiding this comment.
P2: This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_html_export_modes.py, line 64:
<comment>This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.</comment>
<file context>
@@ -0,0 +1,93 @@
+
+def test_default_output_carries_no_loader_and_pinned_csp() -> None:
+ doc = _fig().to_html()
+ assert "xyInflate" not in doc
+ assert "__xyClient" not in doc
+ assert f'content="{export._STANDALONE_CSP}"' in doc
</file context>
|
|
||
| def test_compress_embeds_the_inflate_loader_and_feature_check() -> None: | ||
| doc = _fig().to_html(compress=True) | ||
| assert "function xyInflate(buf)" in doc |
There was a problem hiding this comment.
P2: This test verifies only source-text presence, so a runtime error in DecompressionStream handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_html_export_modes.py, line 44:
<comment>This test verifies only source-text presence, so a runtime error in `DecompressionStream` handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.</comment>
<file context>
@@ -0,0 +1,93 @@
+
+def test_compress_embeds_the_inflate_loader_and_feature_check() -> None:
+ doc = _fig().to_html(compress=True)
+ assert "function xyInflate(buf)" in doc
+ assert "DecompressionStream" in doc
+ # Unsupported browsers get a plain-text notice via textContent, not markup.
</file context>
| | **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract | | ||
| | **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option | | ||
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) | | ||
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) | |
There was a problem hiding this comment.
P2: The row now documents compress=True but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default 1 base64 decode from compressed base64 decode + gzip inflate, and document gzip + base64 re-encoding for the compressed mode.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At spec/design-dossier.md, line 1132:
<comment>The row now documents `compress=True` but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default `1 base64 decode` from compressed `base64 decode + gzip inflate`, and document `gzip + base64` re-encoding for the compressed mode.</comment>
<file context>
@@ -1129,7 +1129,7 @@ user's data structure**, and whether any step re-encodes.
| **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
| **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
-| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
+| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |
| **Native static export** | in-process | 0 | none | — |
</file context>
|
|
||
| def _gzip_deterministic(data: bytes, *, level: int) -> bytes: | ||
| """gzip with a pinned mtime so identical exports stay byte-identical.""" | ||
| return gzip.compress(data, compresslevel=level, mtime=0) |
There was a problem hiding this comment.
P2: _gzip_deterministic still emits a platform-dependent gzip OS header on Python 3.11/3.12, so compress=True is not byte-deterministic across supported environments. Normalize gzip header byte 9 before embedding.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/export.py, line 243:
<comment>`_gzip_deterministic` still emits a platform-dependent gzip OS header on Python 3.11/3.12, so `compress=True` is not byte-deterministic across supported environments. Normalize gzip header byte 9 before embedding.</comment>
<file context>
@@ -215,6 +216,59 @@ def _base64_chunks(blob: bytes) -> list[str]:
+
+def _gzip_deterministic(data: bytes, *, level: int) -> bytes:
+ """gzip with a pinned mtime so identical exports stay byte-identical."""
+ return gzip.compress(data, compresslevel=level, mtime=0)
+
+
</file context>
| return gzip.compress(data, compresslevel=level, mtime=0) | |
| compressed = gzip.compress(data, compresslevel=level, mtime=0) | |
| return compressed[:9] + b"\xff" + compressed[10:] |
| and no `export_config` defaulting — grid geometry is fixed by its panels. | ||
|
|
||
| Legacy per-format surfaces remain: `to_html` (interactive standalone document), | ||
| Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9), |
There was a problem hiding this comment.
P3: This new cross-reference points to_html readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new compress contract.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At spec/api/export.md, line 36:
<comment>This new cross-reference points `to_html` readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new `compress` contract.</comment>
<file context>
@@ -33,7 +33,7 @@ adds one behavior the `Figure` methods do not have: omitted
and no `export_config` defaulting — grid geometry is fixed by its panels.
-Legacy per-format surfaces remain: `to_html` (interactive standalone document),
+Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9),
`Figure.to_png` (`engine=Engine.default` by default), `_svg.to_svg` (carries
`id_prefix=` for composing several exports into one document). `to_png` routes
</file context>
| Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9), | |
| Legacy per-format surfaces remain: `to_html` (interactive standalone document, §10), |
| @@ -0,0 +1 @@ | |||
| Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path. | |||
There was a problem hiding this comment.
P3: Mention the public opt-in explicitly so users know how to enable this feature: add compress=True to the release note.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At news/233.feature.md, line 1:
<comment>Mention the public opt-in explicitly so users know how to enable this feature: add `compress=True` to the release note.</comment>
<file context>
@@ -0,0 +1 @@
+Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path.
</file context>
| Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path. | |
| Standalone HTML exports support opt-in `compress=True` deterministic gzip self-extraction, while the default export retains the allocation-efficient single-join path. |
| | **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract | | ||
| | **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option | | ||
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) | | ||
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) | |
There was a problem hiding this comment.
P3: The compress=True contract is documented in spec/api/export.md §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At spec/design-dossier.md, line 1132:
<comment>The `compress=True` contract is documented in `spec/api/export.md` §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.</comment>
<file context>
@@ -1129,7 +1129,7 @@ user's data structure**, and whether any step re-encodes.
| **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
| **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
-| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
+| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |
| **Native static export** | in-process | 0 | none | — |
</file context>
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) | | |
| | **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §10) | |
| *, | ||
| custom_css: Optional[str] = None, | ||
| animation_progress: Optional[float] = None, | ||
| compress: bool = False, |
There was a problem hiding this comment.
P3: The public API reference omits the new compress parameter from Chart.to_html() and .html(). Add compress=False and document the DecompressionStream compatibility behavior.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/components.py, line 4257:
<comment>The public API reference omits the new `compress` parameter from `Chart.to_html()` and `.html()`. Add `compress=False` and document the `DecompressionStream` compatibility behavior.</comment>
<file context>
@@ -4254,15 +4254,19 @@ def to_html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
+ compress: bool = False,
) -> str:
"""A self-contained HTML document for the chart.
</file context>

What
Opt-in
compress=Trueon the standalone HTML export surfaces (export.to_html,Figure.to_html/html,Chart.to_html/html,FacetGrid.to_html,FacetChart.to_html): the embedded render client and data blob ride as base64-of-gzip chunks and are inflated in-browser via nativeDecompressionStream("gzip")before first render.Why
At small N the inlined client dominates the file — measured 73.6% of a 10k-point export (304 KB client / 413 KB file). Entry-trimmed bundle experiments showed client surgery can't fix this (severable widget-comm code is ~16 KB; the core renderer every path needs is ~187 KB), so the lever is compression at the container level.
Measured
compress=False(default) output is byte-for-byte the pre-existing formatmtime=0, so identical exports stay byte-identical (determinism contract, spec §4)Notes
DecompressionStream(pre-mid-2023) get a plain-text notice viatextContent(never markup) instead of a chart._STANDALONE_CSP.include_plotlyjs='directory'shape) was evaluated, validated, and deliberately dropped — the reflex adapter already provides cached-shared-client economics, and it would widen the CSP and add sibling-file lifecycle.spec/api/export.md§9 (new) and the §29 static-export transport row.Tests
tests/test_html_export_modes.py: loader/feature-check pinning, exact-bytes Python-side gzip roundtrip of client + data chunks, determinism, default-output invariance, facet parity.tests/reflex_adapter/test_assets.py::test_client_source_is_the_installed_bundleexpects unminified markers in the vite-minified bundle — unrelated).pre-commit run --all-files,ruff check .,ruff format --check .all green.