Skip to content

Separate anywidget Model from widget binding lifecycle#8156

Merged
manzt merged 3 commits intomainfrom
manzt/anywidget-binding-refactor
Feb 6, 2026
Merged

Separate anywidget Model from widget binding lifecycle#8156
manzt merged 3 commits intomainfrom
manzt/anywidget-binding-refactor

Conversation

@manzt
Copy link
Collaborator

@manzt manzt commented Feb 6, 2026

The Model class previously owned three independent concerns: widget definition loading (ESM import), model state/events/comm, and the binding between a widget definition and a model instance.

This coupling meant every widget instance ran its own ESM import with no cross-instance deduplication, and Model had to manage AbortControllers and widget definition caching that weren't really its responsibility.

This refactor extracts the widget lifecycle into a new widget-binding.ts module with three focused abstractions:

  • WidgetDefRegistry deduplicates ESM imports by content hash so multiple instances of the same widget share a single import.
  • WidgetBinding owns the initialize/render lifecycle per model, handling hot-reload teardown and view cleanup via AbortSignal.
  • BindingManager maps model IDs to bindings and ensures cleanup on close messages.

Model is now purely about state, events, and comm. it no longer imports AnyWidget types or manages AbortControllers.

@manzt manzt requested a review from Light2Dark as a code owner February 6, 2026 17:18
@vercel
Copy link

vercel bot commented Feb 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Feb 6, 2026 6:01pm

Request Review

The Model class previously owned three independent concerns: widget
definition loading (ESM import), model state/events/comm, and the
binding between a widget definition and a model instance.

This coupling meant every widget instance ran its own ESM import with no
cross-instance deduplication, and Model had to manage AbortControllers
and widget definition caching that weren't really its responsibility.

This refactor extracts the widget lifecycle into a new
`widget-binding.ts` module with three focused abstractions:

- `WidgetDefRegistry` deduplicates ESM imports by content hash so
  multiple instances of the same widget share a single import.
- `WidgetBinding` owns the initialize/render lifecycle per model,
  handling hot-reload teardown and view cleanup via AbortSignal.
- `BindingManager` maps model IDs to bindings and ensures cleanup on
  close messages.

Model is now purely about state, events, and comm. it no longer imports
AnyWidget types or manages AbortControllers.
@manzt manzt force-pushed the manzt/anywidget-binding-refactor branch from f367768 to df24514 Compare February 6, 2026 17:21
@manzt manzt added the enhancement New feature or request label Feb 6, 2026
When a model is closed, its lifecycle signal is aborted. However, the
sendUpdate and sendCustomMessage closures could still fire if a widget
called save_changes or send after the model was torn down. This adds
early returns in both comm methods when signal.aborted is true,
preventing unnecessary network requests to the backend for defunct
models.
@manzt
Copy link
Collaborator Author

manzt commented Feb 6, 2026

Should be good to go

@manzt manzt merged commit 6c08098 into main Feb 6, 2026
29 of 30 checks passed
@manzt manzt deleted the manzt/anywidget-binding-refactor branch February 6, 2026 19:46
@github-actions
Copy link

github-actions bot commented Feb 6, 2026

🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.19.9-dev9

manzt added a commit that referenced this pull request Feb 6, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 8, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 9, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 10, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 10, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 10, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 10, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
manzt added a commit that referenced this pull request Feb 10, 2026
The anywidget refactors in #8156, #8159, and #8163 separated model
lifecycle from widget binding and moved to a dedicated ModelCommand for
frontend-to-backend communication.

However, unlike UpdateUIElementCommand which goes through
SetUIElementRequestManager's drain-and-merge path, ModelCommand was
processed immediately — each rapid model update (e.g. dragging a map
widget) triggered an individual cell re-execution.

These changes puts `ModelCommand` on the same shared queue as
`UpdateUIElementCommand` so both go through the same batching pipeline.
When multiple model updates arrive in quick succession, they are now
drained and merged (last-write-wins per model ID on state keys),
matching the existing UI element behavior.

The handler for model messages also now enqueues the resulting
`UpdateUIElementCommand` back through the control queue instead of
calling `set_ui_element_value` directly, so the downstream cell
re-execution also benefits from batching.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

2 participants