save_state: keep widget notebooks small, still show the render#
The problem#
Every quantem widget (Show2D / Show3D / Show4DSTEM) syncs its pixel buffers to the
frontend as traitlets.Bytes(...).tag(sync=True) traits. When a notebook is saved,
ipywidgets serializes those buffers into metadata.widgets as base64. A 5-panel 4k
Show2D gallery baked ~1 GB into a single .ipynb - the pictures in the cell output
were only ~11 MB; the 1 GB was the embedded widget state.
That state buys almost nothing: JupyterLab does not rehydrate a widget from saved state on a cold reopen - it re-renders from the kernel on Run. So the 1 GB was dead weight except for the rare “reopen kernel-less and keep interacting” case.
The contract#
Every widget takes save_state: bool = False.
save_state=False(default) - do not persist the bulk pixel buffers. Attach a staticimage/pngof the current view to the cell output instead, so a cold reopen (GitHub, nbviewer, kernel-less Lab) still shows how it looked. Live Jupyter still renders the full interactive widget (richest mime wins).save_state=True- embed the full interactive state so a reopened notebook restores the live widget without a kernel. No static PNG (the widget restores itself).
How it is implemented (mirror this for any new widget)#
Six pieces, all in the widget class:
save_state: bool = Falsekwarg in__init__(before**kwargs).self._save_state = bool(save_state)set beforesuper().__init__(**kwargs)(so anyget_stateduring comm-open already sees it)._UNSAVED_HEAVY_KEYS = (...)- the bulksync=TrueBytes traits (frame buffers, prefetch buffers, offline stacks, export blobs). Not the tiny control-sized ones; keep a small current-frame/virtual-image buffer so a cold reopen gets a first paint.get_state(self, key=None, drop_defaults=False)override:state = super().get_state(key=key, drop_defaults=drop_defaults) if key is None and not getattr(self, "_save_state", False): for k in self._UNSAVED_HEAVY_KEYS: state.pop(k, None) return state
The
key is Noneguard is load-bearing. ipywidgets callsget_state(None)only for the full save/embed snapshot. The live-render path (send_state,hold_sync) calls it with a specific key or a set - those must pass through untouched, or the frontend never receives the pixels and the widget renders blank._static_png_b64(...)- a downsampled matplotlib render of the current view, base64. Stride-downsample; rendering full 4k here would dominate display time._repr_mimebundle_override - whennot self._save_state, adddata["image/png"] = self._static_png_b64()(handle the bundle being a(data, metadata)tuple or a dict).
Per-widget heavy keys (as of this writing - verify against the source)#
widget |
|
static PNG source |
|---|---|---|
Show2D |
|
the N panels in |
Show3D |
|
evenly-spaced slices of the z-stack |
Show4DSTEM |
|
the virtual (BF/ADF) image |
Executing notebooks small (the other half)#
The get_state trim covers the Python embed path, but two more things bite when you run
notebooks non-interactively:
metadata.widgetsis sticky.nbconvertdoes not clear an existingmetadata.widgets, so a stale 1 GB blob rides along every rerun even withstore_widget_state=False. Clear it first if a prior run bloated it:nb["metadata"].pop("widgets", None)
The bulk data also rides the comm buffers the widget sends to render (nbconvert records them). Run without persisting those:
jupyter nbconvert --to notebook --execute --inplace \ --ExecutePreprocessor.store_widget_state=False <nb>
With
save_state=False, the static PNG in the cell output preserves the visual.
Verifying a change to this code (the drive-test protocol)#
A screenshot-only pass can miss a blank render, and a size-only pass can miss a broken save. Verify both halves:
Render path intact (browser-free, deterministic): for
save_state=False, assertheavy_key in get_state(heavy_key)(targeted survives) whileheavy_key not in get_state(None)(full snapshot trimmed). This is the exact mechanism that delivers pixels to the frontend; if the targeted lookup keeps the key, live render works. (Show3D’sframe_bytesis empty at construction because it streams on scrub - the key must still be present, just empty.)Visual oracle (browser-free): execute on real data, extract the cell’s
image/png, and look at it. Confirm the panels are the real render, not blank.Live browser drive (when the environment allows headed Chrome): open the notebook on
:1, Run the cell, screenshot the mounted canvas, vision-confirm. This is the only check that exercises the real JS render path; do it when a real GPU + headed Chrome is available (see thewidget-driveskill).
The regression shield for steps 1-2 lives in tests/test_save_state.py (5 invariants ×
3 widgets). Extend it whenever a new widget adopts save_state.