@marimo-team/islands 0.23.12-dev2 → 0.23.12-dev20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/{ConnectedDataExplorerComponent-WqG-xX4l.js → ConnectedDataExplorerComponent-Du3_nUzI.js} +13 -13
  2. package/dist/{ErrorBoundary-BNx_OSVo.js → ErrorBoundary-DE6tzZf-.js} +2 -2
  3. package/dist/{any-language-editor-rPSlOll9.js → any-language-editor-DN1R-1KZ.js} +5 -5
  4. package/dist/{button-vQhauTmO.js → button-BacYv-bE.js} +7 -1
  5. package/dist/{capabilities-BEHzIS99.js → capabilities-D_4LYhSU.js} +1 -1
  6. package/dist/{chat-ui-k2kqhCv5.js → chat-ui-CsPewo4h.js} +16 -16
  7. package/dist/{check-nrzHDi45.js → check-C9OoNtR4.js} +1 -1
  8. package/dist/{code-visibility-DZ_6U5hT.js → code-visibility-02AuLxDs.js} +664 -663
  9. package/dist/{copy-UhDed7D4.js → copy-COam1EG7.js} +2 -2
  10. package/dist/{dist-DYGLrbYQ.js → dist--2Bqjvs0.js} +2 -2
  11. package/dist/{error-banner-BHAkVFc2.js → error-banner-DFPfz_Qf.js} +2 -2
  12. package/dist/{esm-Bqu9AE2K.js → esm-M837UxV5.js} +1 -1
  13. package/dist/{extends-9Yl5BEcg.js → extends-9MVIxxRo.js} +4 -4
  14. package/dist/{formats-BV4bOfMI.js → formats-d6MhLuQ9.js} +4 -4
  15. package/dist/{glide-data-editor-BDTq6YUb.js → glide-data-editor-DkzAInWG.js} +9 -9
  16. package/dist/{html-to-image-C86pQALH.js → html-to-image-DXwLcQ6l.js} +95 -88
  17. package/dist/{input-AKkGXdyV.js → input-CbEz_aj_.js} +6 -6
  18. package/dist/{label-E3ZJXHu8.js → label-WfTSU8L4.js} +2 -2
  19. package/dist/{loader-YPuQvn1Y.js → loader-Boph2xIS.js} +1 -1
  20. package/dist/main.js +1753 -1626
  21. package/dist/{mermaid-QFAR9YgY.js → mermaid-CJW9vIyO.js} +5 -5
  22. package/dist/{process-output-nNw4OpSj.js → process-output-C6_e1pT_.js} +3 -3
  23. package/dist/{reveal-component-BxDb5eK0.js → reveal-component-CX0nM3qj.js} +11 -11
  24. package/dist/{spec-B45_YCNI.js → spec-Bv-XlYiv.js} +4 -4
  25. package/dist/{strings-Cq2s9_EQ.js → strings-Dq_j3Rxw.js} +4 -4
  26. package/dist/style.css +2 -2
  27. package/dist/{swiper-component-BNa_4kh2.js → swiper-component-5HoSsPi1.js} +2 -2
  28. package/dist/{toDate-Do1xRzAo.js → toDate-D-l5s8nn.js} +3 -3
  29. package/dist/{tooltip-Bz3OAwrU.js → tooltip-Czds6Qr8.js} +3 -3
  30. package/dist/{types-D8gEGs4R.js → types-C2Ir191_.js} +1 -1
  31. package/dist/{useAsyncData-CL3o2p4i.js → useAsyncData-1Dhzjfwf.js} +1 -1
  32. package/dist/{useDateFormatter-BC6iSz9g.js → useDateFormatter-CMnRuVmN.js} +2 -2
  33. package/dist/{useDeepCompareMemoize-BPx2MuOK.js → useDeepCompareMemoize-CDWT3BDz.js} +1 -1
  34. package/dist/{useIframeCapabilities-C6Ta3EyP.js → useIframeCapabilities-DWIYvDh7.js} +1 -1
  35. package/dist/{useLifecycle-C3Ec71q0.js → useLifecycle-AHlswLw-.js} +3 -3
  36. package/dist/{useTheme-ZhT6uIu3.js → useTheme-BrYvK-_A.js} +2 -2
  37. package/dist/{vega-component-C3AWYGAL.js → vega-component-Pk6lyc_a.js} +10 -10
  38. package/dist/{zod-DXqkaI_w.js → zod-CijjQh4u.js} +1 -1
  39. package/package.json +3 -3
  40. package/src/components/ai/display-helpers.tsx +5 -5
  41. package/src/components/app-config/ai-config.tsx +5 -5
  42. package/src/components/app-config/mcp-config.tsx +3 -3
  43. package/src/components/chat/acp/agent-panel.tsx +3 -3
  44. package/src/components/chat/acp/blocks.tsx +36 -38
  45. package/src/components/chat/acp/common.tsx +12 -16
  46. package/src/components/chat/acp/scroll-to-bottom-button.tsx +1 -1
  47. package/src/components/chat/acp/session-tabs.tsx +2 -2
  48. package/src/components/chat/chat-history-popover.tsx +1 -1
  49. package/src/components/chat/chat-panel.tsx +47 -23
  50. package/src/components/data-table/TableBottomBar.tsx +4 -1
  51. package/src/components/data-table/columns.tsx +2 -2
  52. package/src/components/data-table/data-table.tsx +26 -17
  53. package/src/components/data-table/filter-pill-editor.tsx +1 -1
  54. package/src/components/dependency-graph/minimap-content.tsx +1 -1
  55. package/src/components/editor/RecoveryButton.tsx +1 -1
  56. package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
  57. package/src/components/editor/actions/useNotebookActions.tsx +4 -4
  58. package/src/components/editor/ai/__tests__/completion-utils.test.ts +91 -1
  59. package/src/components/editor/ai/ai-completion-editor.tsx +1 -1
  60. package/src/components/editor/ai/completion-utils.ts +86 -1
  61. package/src/components/editor/cell/CreateCellButton.tsx +1 -1
  62. package/src/components/editor/chrome/panels/empty-state.tsx +1 -1
  63. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +1 -1
  64. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +1 -1
  65. package/src/components/editor/columns/cell-column.tsx +1 -1
  66. package/src/components/editor/columns/sortable-column.tsx +2 -2
  67. package/src/components/editor/output/MarimoErrorOutput.tsx +1 -1
  68. package/src/components/editor/output/TextOutput.tsx +2 -2
  69. package/src/components/home/components.tsx +4 -4
  70. package/src/components/icons/github.tsx +21 -0
  71. package/src/components/icons/youtube.tsx +21 -0
  72. package/src/components/slides/minimap.tsx +2 -2
  73. package/src/components/slides/reveal-component.tsx +1 -1
  74. package/src/components/storage/components.tsx +3 -7
  75. package/src/components/ui/alert.tsx +1 -1
  76. package/src/components/ui/command.tsx +2 -2
  77. package/src/components/ui/reorderable-list.tsx +1 -1
  78. package/src/components/ui/table.tsx +2 -5
  79. package/src/core/codemirror/go-to-definition/__tests__/commands.test.ts +67 -0
  80. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +47 -0
  81. package/src/core/codemirror/go-to-definition/commands.ts +47 -30
  82. package/src/core/codemirror/go-to-definition/utils.ts +0 -1
  83. package/src/core/codemirror/language/languages/sql/renderers.tsx +60 -68
  84. package/src/core/codemirror/reactive-references/__tests__/analyzer.test.ts +54 -0
  85. package/src/core/codemirror/reactive-references/analyzer.ts +44 -35
  86. package/src/core/hotkeys/hotkeys.ts +1 -0
  87. package/src/core/islands/__tests__/bridge.test.ts +25 -0
  88. package/src/core/islands/__tests__/parse.test.ts +585 -1
  89. package/src/core/islands/__tests__/test-utils.tsx +10 -1
  90. package/src/core/islands/bridge.ts +6 -1
  91. package/src/core/islands/constants.ts +2 -0
  92. package/src/core/islands/parse.ts +293 -13
  93. package/src/plugins/impl/DataTablePlugin.tsx +20 -1
  94. package/src/plugins/impl/FileBrowserPlugin.tsx +165 -74
  95. package/src/plugins/impl/MatrixPlugin.tsx +2 -2
  96. package/src/plugins/impl/TabsPlugin.tsx +1 -1
  97. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +141 -1
  98. package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +314 -0
  99. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +4 -1
  100. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +34 -0
  101. package/src/plugins/impl/anywidget/__tests__/model.test.ts +19 -0
  102. package/src/plugins/impl/anywidget/model.ts +15 -0
  103. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -1
  104. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +155 -98
  105. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +154 -1
  106. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +10 -0
@@ -0,0 +1,314 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { SetupMocks } from "@/__mocks__/common";
6
+ import { initialModeAtom } from "@/core/mode";
7
+ import { store } from "@/core/state/jotai";
8
+ import type { IPluginProps } from "../../types";
9
+ import { FileBrowserPlugin } from "../FileBrowserPlugin";
10
+
11
+ interface MockFile {
12
+ id: string;
13
+ path: string;
14
+ name: string;
15
+ is_directory: boolean;
16
+ }
17
+
18
+ const FILES: MockFile[] = [
19
+ { id: "1", path: "/home/user/docs", name: "docs", is_directory: true },
20
+ { id: "2", path: "/home/user/a.txt", name: "a.txt", is_directory: false },
21
+ { id: "3", path: "/home/user/b.txt", name: "b.txt", is_directory: false },
22
+ ];
23
+
24
+ type Value = MockFile[];
25
+
26
+ function mockListDirectory(files: MockFile[]) {
27
+ return vi.fn().mockResolvedValue({
28
+ files,
29
+ total_count: files.length,
30
+ is_truncated: false,
31
+ });
32
+ }
33
+
34
+ function makeProps(
35
+ overrides: {
36
+ selectionMode?: string;
37
+ multiple?: boolean;
38
+ value?: Value;
39
+ setValue?: (v: Value) => void;
40
+ files?: MockFile[];
41
+ list_directory?: ReturnType<typeof vi.fn>;
42
+ host?: HTMLElement;
43
+ } = {},
44
+ ): IPluginProps<Value, Record<string, unknown>> {
45
+ const files = overrides.files ?? FILES;
46
+ const list_directory = overrides.list_directory ?? mockListDirectory(files);
47
+ return {
48
+ data: {
49
+ initialPath: "/home/user",
50
+ filetypes: [],
51
+ selectionMode: overrides.selectionMode ?? "all",
52
+ multiple: overrides.multiple ?? true,
53
+ label: null,
54
+ restrictNavigation: false,
55
+ },
56
+ value: overrides.value ?? [],
57
+ setValue: overrides.setValue ?? vi.fn(),
58
+ host: overrides.host ?? document.createElement("div"),
59
+ functions: {
60
+ list_directory,
61
+ },
62
+ } as unknown as IPluginProps<Value, Record<string, unknown>>;
63
+ }
64
+
65
+ function renderBrowser(overrides: Parameters<typeof makeProps>[0] = {}) {
66
+ const listDirectory =
67
+ overrides.list_directory ?? mockListDirectory(overrides.files ?? FILES);
68
+ const result = render(
69
+ FileBrowserPlugin.render(
70
+ makeProps({ ...overrides, list_directory: listDirectory }) as Parameters<
71
+ typeof FileBrowserPlugin.render
72
+ >[0],
73
+ ),
74
+ );
75
+ return { ...result, listDirectory };
76
+ }
77
+
78
+ beforeAll(() => {
79
+ SetupMocks.resizeObserver();
80
+ store.set(initialModeAtom, "edit");
81
+ });
82
+
83
+ describe("FileBrowserPlugin keyboard accessibility", () => {
84
+ it("renders a row per file plus the parent row", async () => {
85
+ renderBrowser();
86
+ expect(await screen.findByText("docs")).toBeInTheDocument();
87
+ // parent "..", docs, a.txt, b.txt
88
+ expect(screen.getAllByRole("row")).toHaveLength(4);
89
+ });
90
+
91
+ it("marks the list as a multiselectable grid", async () => {
92
+ renderBrowser({ multiple: true });
93
+ await screen.findByText("docs");
94
+ const grid = screen.getByRole("grid");
95
+ expect(grid).toHaveAttribute("aria-multiselectable", "true");
96
+ });
97
+
98
+ it("does not select a non-selectable file on click (mode=directory)", async () => {
99
+ const setValue = vi.fn();
100
+ renderBrowser({ selectionMode: "directory", setValue });
101
+ const fileCell = await screen.findByText("a.txt");
102
+ fireEvent.click(fileCell.closest('[role="row"]')!);
103
+ expect(setValue).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("labels each selectable checkbox with the file name", async () => {
107
+ renderBrowser({ selectionMode: "all" });
108
+ await screen.findByText("docs");
109
+ expect(
110
+ screen.getByRole("checkbox", { name: "Select a.txt" }),
111
+ ).toBeInTheDocument();
112
+ expect(
113
+ screen.getByRole("checkbox", { name: "Select docs" }),
114
+ ).toBeInTheDocument();
115
+ });
116
+
117
+ it("keeps checkboxes out of the tab order", async () => {
118
+ renderBrowser({ selectionMode: "all" });
119
+ await screen.findByText("docs");
120
+ expect(
121
+ screen.getByRole("checkbox", { name: "Select a.txt" }),
122
+ ).toHaveAttribute("tabindex", "-1");
123
+ });
124
+
125
+ it("places exactly one row in the tab order", async () => {
126
+ renderBrowser();
127
+ await screen.findByText("docs");
128
+ const rows = screen.getAllByRole("row");
129
+ const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
130
+ expect(tabbable).toHaveLength(1);
131
+ // the parent row is first and starts active
132
+ expect(rows[0]).toHaveAttribute("tabindex", "0");
133
+ });
134
+
135
+ it("resets the active row to the parent row after navigating", async () => {
136
+ renderBrowser({ selectionMode: "all" });
137
+ const docs = await screen.findByText("docs");
138
+ const docsRow = docs.closest('[role="row"]')!;
139
+ fireEvent.keyDown(docsRow, { key: "ArrowDown" }); // move active off the parent
140
+ fireEvent.click(docs); // navigate into "docs"
141
+ await screen.findByText("docs"); // listing reloads (mock returns same files)
142
+ const rows = screen.getAllByRole("row");
143
+ expect(rows[0]).toHaveAttribute("tabindex", "0");
144
+ // the focused row must match the only tabbable row
145
+ expect(rows[0]).toHaveFocus();
146
+ });
147
+
148
+ it("resets the active row when the listing refreshes in place", async () => {
149
+ // The cell's random-id changes when the cell re-renders, refetching the
150
+ // same path. A shrinking listing must not leave activeIndex out of bounds.
151
+ const parent = document.createElement("div");
152
+ parent.setAttribute("random-id", "a");
153
+ const host = document.createElement("div");
154
+ parent.append(host);
155
+ document.body.append(parent);
156
+
157
+ const list_directory = vi
158
+ .fn()
159
+ .mockResolvedValueOnce({
160
+ files: FILES,
161
+ total_count: FILES.length,
162
+ is_truncated: false,
163
+ })
164
+ .mockResolvedValue({
165
+ files: [FILES[0]],
166
+ total_count: 1,
167
+ is_truncated: false,
168
+ });
169
+
170
+ const props = () =>
171
+ FileBrowserPlugin.render(
172
+ makeProps({ host, list_directory }) as Parameters<
173
+ typeof FileBrowserPlugin.render
174
+ >[0],
175
+ );
176
+ const { rerender } = render(props());
177
+ await screen.findByText("a.txt");
178
+
179
+ // Move the active row to the last row, then trigger a same-path refresh.
180
+ fireEvent.keyDown(screen.getAllByRole("row")[0], { key: "End" });
181
+ expect(screen.getAllByRole("row").at(-1)).toHaveAttribute("tabindex", "0");
182
+
183
+ parent.setAttribute("random-id", "b");
184
+ rerender(props());
185
+
186
+ await waitFor(() =>
187
+ // parent ".." plus the single remaining file
188
+ expect(screen.getAllByRole("row")).toHaveLength(2),
189
+ );
190
+ const rows = screen.getAllByRole("row");
191
+ const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
192
+ expect(tabbable).toHaveLength(1);
193
+ expect(rows[0]).toHaveAttribute("tabindex", "0");
194
+ });
195
+
196
+ it("syncs the tabbable row to whichever row gains focus", async () => {
197
+ renderBrowser();
198
+ await screen.findByText("docs");
199
+ const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
200
+ expect(rows[0]).toHaveAttribute("tabindex", "0");
201
+
202
+ fireEvent.focus(rows[2]); // e.g. focus from a mouse click, not arrow keys
203
+ expect(rows[2]).toHaveAttribute("tabindex", "0");
204
+ expect(rows[0]).toHaveAttribute("tabindex", "-1");
205
+ });
206
+
207
+ it("moves focus to the parent row after navigating from within the grid", async () => {
208
+ // Navigating into a directory unmounts the focused row; focus must follow
209
+ // to the parent row instead of falling back to the document body.
210
+ const list_directory = vi
211
+ .fn()
212
+ .mockResolvedValueOnce({
213
+ files: FILES,
214
+ total_count: FILES.length,
215
+ is_truncated: false,
216
+ })
217
+ .mockResolvedValue({
218
+ files: [
219
+ {
220
+ id: "99",
221
+ path: "/home/user/docs/inner.txt",
222
+ name: "inner.txt",
223
+ is_directory: false,
224
+ },
225
+ ],
226
+ total_count: 1,
227
+ is_truncated: false,
228
+ });
229
+ renderBrowser({ selectionMode: "all", list_directory });
230
+ await screen.findByText("docs");
231
+
232
+ const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
233
+ fireEvent.keyDown(rows[0], { key: "ArrowDown" }); // focus the docs row
234
+ expect(rows[1]).toHaveFocus();
235
+ fireEvent.keyDown(rows[1], { key: "Enter" }); // navigate into docs
236
+
237
+ await screen.findByText("inner.txt");
238
+ expect(screen.getAllByRole("row")[0]).toHaveFocus();
239
+ });
240
+
241
+ it("moves focus with arrows and clamps at the ends", async () => {
242
+ renderBrowser();
243
+ await screen.findByText("docs");
244
+ const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
245
+
246
+ fireEvent.keyDown(rows[0], { key: "ArrowDown" });
247
+ expect(rows[1]).toHaveFocus();
248
+ expect(rows[1]).toHaveAttribute("tabindex", "0");
249
+
250
+ fireEvent.keyDown(rows[1], { key: "ArrowUp" });
251
+ expect(rows[0]).toHaveFocus();
252
+
253
+ fireEvent.keyDown(rows[0], { key: "ArrowUp" }); // clamp at top
254
+ expect(rows[0]).toHaveFocus();
255
+
256
+ fireEvent.keyDown(rows[0], { key: "End" });
257
+ expect(rows[3]).toHaveFocus();
258
+
259
+ fireEvent.keyDown(rows[3], { key: "ArrowDown" }); // clamp at bottom
260
+ expect(rows[3]).toHaveFocus();
261
+
262
+ fireEvent.keyDown(rows[3], { key: "Home" });
263
+ expect(rows[0]).toHaveFocus();
264
+ });
265
+
266
+ it("Enter navigates into a directory", async () => {
267
+ const setValue = vi.fn();
268
+ const { listDirectory } = renderBrowser({
269
+ selectionMode: "all",
270
+ setValue,
271
+ });
272
+ await screen.findByText("docs");
273
+ fireEvent.keyDown(rowFor("docs"), { key: "Enter" });
274
+ await waitFor(() =>
275
+ expect(listDirectory).toHaveBeenCalledWith({ path: "/home/user/docs" }),
276
+ );
277
+ // navigation does not mutate value
278
+ expect(setValue).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it("Enter toggles selection on a selectable file", async () => {
282
+ const setValue = vi.fn();
283
+ renderBrowser({ selectionMode: "all", multiple: true, setValue });
284
+ await screen.findByText("a.txt");
285
+ fireEvent.keyDown(rowFor("a.txt"), { key: "Enter" });
286
+ expect(setValue).toHaveBeenCalledWith([
287
+ expect.objectContaining({ path: "/home/user/a.txt" }),
288
+ ]);
289
+ });
290
+
291
+ it("Space toggles selection and never navigates", async () => {
292
+ const setValue = vi.fn();
293
+ renderBrowser({ selectionMode: "all", multiple: true, setValue });
294
+ await screen.findByText("docs");
295
+ // Space on a selectable directory selects it (does not navigate)
296
+ fireEvent.keyDown(rowFor("docs"), { key: " " });
297
+ expect(setValue).toHaveBeenCalledWith([
298
+ expect.objectContaining({ path: "/home/user/docs" }),
299
+ ]);
300
+ });
301
+
302
+ it("Space on the parent row is a no-op", async () => {
303
+ const setValue = vi.fn();
304
+ renderBrowser({ selectionMode: "all", setValue });
305
+ await screen.findByText("docs");
306
+ const parentRow = screen.getAllByRole("row")[0];
307
+ fireEvent.keyDown(parentRow, { key: " " });
308
+ expect(setValue).not.toHaveBeenCalled();
309
+ });
310
+ });
311
+
312
+ function rowFor(name: string): HTMLElement {
313
+ return screen.getByText(name).closest('[role="row"]') as HTMLElement;
314
+ }
@@ -11,7 +11,7 @@ import type { IPluginProps } from "@/plugins/types";
11
11
  import { prettyError } from "@/utils/errors";
12
12
  import { Logger } from "@/utils/Logger";
13
13
  import { ErrorBanner } from "../common/error-banner";
14
- import { MODEL_MANAGER, type Model } from "./model";
14
+ import { getMarimoInternal, MODEL_MANAGER, type Model } from "./model";
15
15
  import type { ModelState, WidgetModelId } from "./types";
16
16
  import { BINDING_MANAGER, WIDGET_DEF_REGISTRY } from "./widget-binding";
17
17
 
@@ -178,6 +178,9 @@ async function runAnyWidgetModule<T extends AnyWidgetState>(
178
178
  const binding = BINDING_MANAGER.getOrCreate(modelId);
179
179
  const render = await binding.bind(widgetDef, model);
180
180
  await render(el, signal);
181
+ // Replay current model values so render listeners observe hydrated state
182
+ // even if backend updates arrived before listeners were attached.
183
+ getMarimoInternal(model).reemitState();
181
184
  } catch (error) {
182
185
  Logger.error("Error rendering anywidget", error);
183
186
  el.classList.add("text-error");
@@ -115,4 +115,38 @@ describe("LoadedSlot", () => {
115
115
  expect(newMockWidget.render).toHaveBeenCalled();
116
116
  });
117
117
  });
118
+
119
+ it("should hydrate view state even when listener attaches late", async () => {
120
+ mockModel = new Model(
121
+ { count: 8 },
122
+ {
123
+ sendUpdate: vi.fn().mockResolvedValue(undefined),
124
+ sendCustomMessage: vi.fn().mockResolvedValue(undefined),
125
+ },
126
+ );
127
+ MODEL_MANAGER.set(modelId, mockModel);
128
+
129
+ const lateListenerWidget = {
130
+ initialize: vi.fn(),
131
+ render: vi.fn(({ model, el }) => {
132
+ // Simulate a widget view that starts with a local default and
133
+ // relies on change events for hydration.
134
+ el.textContent = "count is 5";
135
+ const onCount = () => {
136
+ el.textContent = `count is ${model.get("count")}`;
137
+ };
138
+ model.on("change:count", onCount);
139
+ return () => model.off("change:count", onCount);
140
+ }),
141
+ };
142
+
143
+ const { container } = render(
144
+ <LoadedSlot {...mockProps} widget={lateListenerWidget} />,
145
+ );
146
+
147
+ await waitFor(() => {
148
+ expect(lateListenerWidget.render).toHaveBeenCalled();
149
+ expect(container.textContent).toContain("count is 8");
150
+ });
151
+ });
118
152
  });
@@ -272,6 +272,25 @@ describe("Model", () => {
272
272
  });
273
273
  });
274
274
 
275
+ describe("reemitState", () => {
276
+ it("should emit change events for current values without state changes", async () => {
277
+ const onFoo = vi.fn();
278
+ const onBar = vi.fn();
279
+ const onAny = vi.fn();
280
+
281
+ model.on("change:foo", onFoo);
282
+ model.on("change:bar", onBar);
283
+ model.on("change", onAny);
284
+
285
+ getMarimoInternal(model).reemitState();
286
+ await TestUtils.nextTick();
287
+
288
+ expect(onFoo).toHaveBeenCalledWith("test");
289
+ expect(onBar).toHaveBeenCalledWith(123);
290
+ expect(onAny).toHaveBeenCalledTimes(1);
291
+ });
292
+ });
293
+
275
294
  describe("emitCustomMessage", () => {
276
295
  it("should handle custom messages", () => {
277
296
  const callback = vi.fn();
@@ -112,6 +112,10 @@ interface MarimoInternalApi<T extends ModelState> {
112
112
  * Update model state and emit change events for any differences.
113
113
  */
114
114
  updateAndEmitDiffs: (value: T) => void;
115
+ /**
116
+ * Re-emit current state as change events.
117
+ */
118
+ reemitState: () => void;
115
119
  /**
116
120
  * Emit a custom message to listeners.
117
121
  */
@@ -160,6 +164,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
160
164
  */
161
165
  [marimoSymbol]: MarimoInternalApi<T> = {
162
166
  updateAndEmitDiffs: (value: T) => this.#updateAndEmitDiffs(value),
167
+ reemitState: () => this.#reemitState(),
163
168
  emitCustomMessage: (
164
169
  message: Extract<AnyWidgetMessage, { method: "custom" }>,
165
170
  buffers?: readonly DataView[],
@@ -269,6 +274,16 @@ export class Model<T extends ModelState> implements AnyModel<T> {
269
274
  });
270
275
  }
271
276
 
277
+ #reemitState() {
278
+ for (const [key, value] of Object.entries(this.#data) as [
279
+ keyof T & string,
280
+ T[keyof T],
281
+ ][]) {
282
+ this.#emit(`change:${key}`, value);
283
+ }
284
+ this.#emitAnyChange();
285
+ }
286
+
272
287
  /**
273
288
  * When receiving a message from the backend.
274
289
  * We want to notify all listeners with `msg:custom`
@@ -292,7 +292,7 @@ export class MatplotlibRenderer {
292
292
  // Configure container
293
293
  container.tabIndex = -1;
294
294
  container.role = "application";
295
- container.className = "relative inline-block select-none outline-none";
295
+ container.className = "relative inline-block select-none outline-hidden";
296
296
 
297
297
  // Create canvas
298
298
  const canvas = document.createElement("canvas");