Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0b9b933
fix: echo_update messages are being recorded in replay state instead …
Shamik-07 May 4, 2026
a65acfb
feat: adding an echo_update test for replay state.
Shamik-07 May 4, 2026
5ff656c
feat: added reemitState method to re-emit all current state as change…
Shamik-07 May 4, 2026
bef476a
test: adding a reemit state for model.
Shamik-07 May 4, 2026
9e4fb8f
fix: replay current model values with reemit state so that even if ba…
Shamik-07 May 4, 2026
c56976b
test: test for reemit state.
Shamik-07 May 4, 2026
2aa2d5e
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 4, 2026
59d80f2
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 5, 2026
0cab748
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 6, 2026
cc7879c
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 7, 2026
af8fb2a
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 8, 2026
7fac9de
docs: updated docstring of _create_model_message as echo_update metho…
Shamik-07 May 11, 2026
8058be6
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 11, 2026
771cafa
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 11, 2026
5f6d6c8
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 12, 2026
b96acc8
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 13, 2026
c1b3b86
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 13, 2026
444fa75
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 14, 2026
bba0f1b
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 15, 2026
68af249
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 19, 2026
af7131b
fix: linting error.
Shamik-07 May 19, 2026
9c8b430
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 20, 2026
90cdbfe
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 21, 2026
a92660c
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 21, 2026
8d5845e
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 22, 2026
328beca
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 25, 2026
ee521cc
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 26, 2026
aeb5dc7
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 27, 2026
e085c96
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 28, 2026
0b5f359
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 May 29, 2026
9d75eb8
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 Jun 1, 2026
718d8a4
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 Jun 2, 2026
190574e
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 Jun 2, 2026
49cb2eb
Merge branch 'main' into fix/anywidget_refresh_value
Shamik-07 Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,6 @@ pixi.lock

# mkdocs
site

.agents/
*skills*
5 changes: 4 additions & 1 deletion frontend/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { IPluginProps } from "@/plugins/types";
import { prettyError } from "@/utils/errors";
import { Logger } from "@/utils/Logger";
import { ErrorBanner } from "../common/error-banner";
import { MODEL_MANAGER, type Model } from "./model";
import { getMarimoInternal, MODEL_MANAGER, type Model } from "./model";
import type { ModelState, WidgetModelId } from "./types";
import { BINDING_MANAGER, WIDGET_DEF_REGISTRY } from "./widget-binding";

Expand Down Expand Up @@ -178,6 +178,9 @@ async function runAnyWidgetModule<T extends AnyWidgetState>(
const binding = BINDING_MANAGER.getOrCreate(modelId);
const render = await binding.bind(widgetDef, model);
await render(el, signal);
// Replay current model values so render listeners observe hydrated state
// even if backend updates arrived before listeners were attached.
getMarimoInternal(model).reemitState();
} catch (error) {
Logger.error("Error rendering anywidget", error);
el.classList.add("text-error");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,38 @@ describe("LoadedSlot", () => {
expect(newMockWidget.render).toHaveBeenCalled();
});
});

it("should hydrate view state even when listener attaches late", async () => {
mockModel = new Model(
{ count: 8 },
{
sendUpdate: vi.fn().mockResolvedValue(undefined),
sendCustomMessage: vi.fn().mockResolvedValue(undefined),
},
);
MODEL_MANAGER.set(modelId, mockModel);

const lateListenerWidget = {
initialize: vi.fn(),
render: vi.fn(({ model, el }) => {
// Simulate a widget view that starts with a local default and
// relies on change events for hydration.
el.textContent = "count is 5";
const onCount = () => {
el.textContent = `count is ${model.get("count")}`;
};
model.on("change:count", onCount);
return () => model.off("change:count", onCount);
}),
};

const { container } = render(
<LoadedSlot {...mockProps} widget={lateListenerWidget} />,
);

await waitFor(() => {
expect(lateListenerWidget.render).toHaveBeenCalled();
expect(container.textContent).toContain("count is 8");
});
});
});
19 changes: 19 additions & 0 deletions frontend/src/plugins/impl/anywidget/__tests__/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,25 @@ describe("Model", () => {
});
});

describe("reemitState", () => {
it("should emit change events for current values without state changes", async () => {
const onFoo = vi.fn();
const onBar = vi.fn();
const onAny = vi.fn();

model.on("change:foo", onFoo);
model.on("change:bar", onBar);
model.on("change", onAny);

getMarimoInternal(model).reemitState();
await TestUtils.nextTick();

expect(onFoo).toHaveBeenCalledWith("test");
expect(onBar).toHaveBeenCalledWith(123);
expect(onAny).toHaveBeenCalledTimes(1);
});
});

describe("emitCustomMessage", () => {
it("should handle custom messages", () => {
const callback = vi.fn();
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/plugins/impl/anywidget/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ interface MarimoInternalApi<T extends ModelState> {
* Update model state and emit change events for any differences.
*/
updateAndEmitDiffs: (value: T) => void;
/**
* Re-emit current state as change events.
*/
reemitState: () => void;
/**
* Emit a custom message to listeners.
*/
Expand Down Expand Up @@ -160,6 +164,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
*/
[marimoSymbol]: MarimoInternalApi<T> = {
updateAndEmitDiffs: (value: T) => this.#updateAndEmitDiffs(value),
reemitState: () => this.#reemitState(),
emitCustomMessage: (
message: Extract<AnyWidgetMessage, { method: "custom" }>,
buffers?: readonly DataView[],
Expand Down Expand Up @@ -269,6 +274,16 @@ export class Model<T extends ModelState> implements AnyModel<T> {
});
}

#reemitState() {
for (const [key, value] of Object.entries(this.#data) as [
keyof T & string,
T[keyof T],
][]) {
this.#emit(`change:${key}`, value);
}
this.#emitAnyChange();
}

/**
* When receiving a message from the backend.
* We want to notify all listeners with `msg:custom`
Expand Down
14 changes: 11 additions & 3 deletions marimo/_plugins/ui/_impl/comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ def _create_model_message(
) -> ModelMessage | None:
"""Create the appropriate ModelMessage based on the method field.

Returns None for methods that should be skipped (e.g., echo_update).
Returns None for unknown methods that should be skipped.
`echo_update` is converted to `ModelUpdate` to preserve
frontend-driven trait changes for reconnect replay.
"""
bbuffers = [_ensure_bytes(b) for b in buffers]
method = data.get("method", "update")
Expand All @@ -120,8 +122,14 @@ def _create_model_message(
buffers=bbuffers,
)
elif method == "echo_update":
# echo_update is for multi-client sync acknowledgment, skip it
return None
# Preserve frontend-driven trait changes for reconnect replay.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
# anywidget/ipywidgets can emit echo_update as the synchronisation
# acknowledgement path; dropping it causes stale replay state.
return ModelUpdate(
Comment on lines 124 to +128

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @manzt

If you could take a look at this, before it becomes stale, that would be great.

Thank you

state=state,
buffer_paths=buffer_paths,
buffers=bbuffers,
)
else:
LOGGER.warning("Unknown method: %s, skipping", method)
return None
Expand Down
15 changes: 15 additions & 0 deletions tests/_plugins/ui/_impl/test_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ def test_comm_broadcast(comm: MarimoComm):
assert notification.model_id == comm.comm_id


def test_comm_broadcast_echo_update(comm: MarimoComm):
"""echo_update should still contribute to replay state."""
with patch(
"marimo._plugins.ui._impl.comm.broadcast_notification"
) as mock_broadcast:
comm._broadcast(
{"method": "echo_update", "state": {"key": "value"}},
[],
)
mock_broadcast.assert_called_once()
notification = mock_broadcast.call_args[0][0]
assert notification.model_id == comm.comm_id
assert notification.message.state == {"key": "value"}


def test_comm_manager_receive_update_message(
comm_manager: MarimoCommManager, comm: MarimoComm
):
Expand Down
Loading