@marimo-team/islands 0.23.15-dev4 → 0.23.15-dev40

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 (92) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CU4fZJzG.js → ConnectedDataExplorerComponent-7p7rt9iw.js} +4 -4
  2. package/dist/{ErrorBoundary-DE6tzZf-.js → ErrorBoundary-B_CAG7_e.js} +1 -1
  3. package/dist/{ImageComparisonComponent-CHrI72em.js → ImageComparisonComponent-1i5IBhon.js} +173 -129
  4. package/dist/assets/__vite-browser-external-CjNAy01L.js +1 -0
  5. package/dist/assets/worker-B3XPCb6y.js +98 -0
  6. package/dist/{chat-ui-DinOvbWu.js → chat-ui-C0z13gJB.js} +3111 -3111
  7. package/dist/{code-visibility-Dk8cVOny.js → code-visibility-CSsHnVZu.js} +946 -864
  8. package/dist/{constants-T20xxyNf.js → debounce-BOD3DbfP.js} +1 -24
  9. package/dist/{formats-Dzx4J_z1.js → formats-CitsMtUm.js} +1 -1
  10. package/dist/{glide-data-editor-CjTu7ukN.js → glide-data-editor-CwZz71BD.js} +3 -3
  11. package/dist/{html-to-image-_wGfk8V-.js → html-to-image-CEo5pRYw.js} +2291 -2250
  12. package/dist/{input-DtsN7xm-.js → input-BGPrFH3g.js} +2 -2
  13. package/dist/main.js +1297 -1201
  14. package/dist/{mermaid-BYqXy_NE.js → mermaid-UdmxG2PZ.js} +2 -2
  15. package/dist/{process-output-Mh4UrjwM.js → process-output-BOvvilRG.js} +1 -1
  16. package/dist/{reveal-component-B5DXLyO7.js → reveal-component-DGjJVNMQ.js} +618 -605
  17. package/dist/{spec-Cz-Bj1JI.js → spec-DSs9v0xx.js} +1 -1
  18. package/dist/style.css +1 -1
  19. package/dist/{toDate-CWNNlFEX.js → toDate-BRJgtOGm.js} +14 -5
  20. package/dist/{useAsyncData-KfHB8wQR.js → useAsyncData-BSOyAbac.js} +1 -1
  21. package/dist/{useDeepCompareMemoize-nJMtxhm4.js → useDeepCompareMemoize-BTLeWzuR.js} +1 -1
  22. package/dist/{useLifecycle-DegSo0lV.js → useLifecycle-C6wHjkhW.js} +1 -1
  23. package/dist/{useTheme-6eZ3GOTS.js → useTheme-Df_vGflw.js} +62 -27
  24. package/dist/{vega-component-DzyyM9fc.js → vega-component-BAvmvTjO.js} +6 -6
  25. package/package.json +1 -1
  26. package/src/components/chat/__tests__/chat-utils.test.ts +244 -1
  27. package/src/components/chat/__tests__/message-queue.test.tsx +121 -0
  28. package/src/components/chat/chat-panel.tsx +196 -67
  29. package/src/components/chat/chat-utils.ts +111 -2
  30. package/src/components/editor/SortableCell.tsx +6 -2
  31. package/src/components/editor/__tests__/output-persistence.test.tsx +241 -0
  32. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +153 -0
  33. package/src/components/editor/actions/pair-with-agent-commands.ts +109 -0
  34. package/src/components/editor/actions/pair-with-agent-modal.tsx +20 -64
  35. package/src/components/editor/ai/add-cell-with-ai.tsx +14 -5
  36. package/src/components/editor/ai/ai-completion-editor.tsx +4 -1
  37. package/src/components/editor/cell/cell-context-menu.tsx +7 -0
  38. package/src/components/editor/chrome/panels/packages-panel.tsx +11 -1
  39. package/src/components/editor/columns/__tests__/cell-column.test.tsx +105 -0
  40. package/src/components/editor/columns/cell-column.tsx +34 -4
  41. package/src/components/editor/columns/sortable-column.tsx +24 -4
  42. package/src/components/editor/file-tree/download.ts +46 -0
  43. package/src/components/editor/file-tree/file-explorer.tsx +3 -21
  44. package/src/components/editor/file-tree/file-viewer.tsx +4 -27
  45. package/src/components/editor/navigation/__tests__/navigation.test.ts +33 -0
  46. package/src/components/editor/navigation/navigation.ts +8 -1
  47. package/src/components/editor/notebook-cell.tsx +203 -106
  48. package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +66 -0
  49. package/src/components/editor/renderers/cell-array.tsx +26 -13
  50. package/src/components/editor/renderers/cells-renderer.tsx +8 -2
  51. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +29 -0
  52. package/src/components/editor/renderers/slides-layout/types.ts +4 -0
  53. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
  54. package/src/components/slides/reveal-component.tsx +37 -4
  55. package/src/components/slides/slide-form.tsx +72 -0
  56. package/src/components/ui/toast.tsx +11 -3
  57. package/src/components/ui/toaster.tsx +65 -9
  58. package/src/core/cells/__tests__/utils.test.ts +59 -0
  59. package/src/core/cells/utils.ts +51 -0
  60. package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +97 -3
  61. package/src/core/codemirror/completion/signature-hint.ts +78 -11
  62. package/src/core/constants.ts +6 -0
  63. package/src/core/islands/__tests__/bridge.test.ts +341 -46
  64. package/src/core/islands/__tests__/main.test.ts +176 -0
  65. package/src/core/islands/__tests__/parse.test.ts +105 -0
  66. package/src/core/islands/bootstrap.ts +8 -3
  67. package/src/core/islands/bridge.ts +116 -23
  68. package/src/core/islands/components/__tests__/web-components.test.tsx +214 -0
  69. package/src/core/islands/components/web-components.tsx +76 -15
  70. package/src/core/islands/constants.ts +1 -0
  71. package/src/core/islands/main.ts +69 -3
  72. package/src/core/islands/parse.ts +70 -23
  73. package/src/core/islands/worker/__tests__/controller.test.ts +173 -0
  74. package/src/core/islands/worker/worker.tsx +145 -57
  75. package/src/core/runtime/__tests__/runtime.test.ts +64 -0
  76. package/src/core/runtime/runtime.ts +30 -10
  77. package/src/core/wasm/worker/bootstrap.ts +113 -20
  78. package/src/css/app/Cell.css +10 -0
  79. package/src/hooks/__tests__/useHotkey.test.tsx +88 -0
  80. package/src/hooks/useHotkey.ts +29 -4
  81. package/src/plugins/impl/anywidget/__tests__/host.test.ts +6 -9
  82. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +22 -61
  83. package/src/plugins/impl/anywidget/model-proxy.ts +0 -13
  84. package/src/plugins/impl/anywidget/widget-binding.ts +4 -34
  85. package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +53 -2
  86. package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +71 -0
  87. package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +71 -3
  88. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -0
  89. package/src/theme/__tests__/useTheme.test.ts +68 -0
  90. package/src/theme/useTheme.ts +16 -1
  91. package/dist/assets/__vite-browser-external-BQCLNwri.js +0 -1
  92. package/dist/assets/worker-DAWRHcPq.js +0 -73
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { loadPyodide, type PyodideInterface } from "pyodide";
3
+ import type { PyCallable, PyProxy } from "pyodide/ffi";
3
4
  import type { UserConfig } from "@/core/config/config-schema";
4
5
  import type { NotificationPayload } from "@/core/kernel/messages";
5
6
  import type { JsonString } from "@/utils/json/base64";
@@ -12,6 +13,12 @@ import type { SerializedBridge, WasmController } from "./types";
12
13
  import { shouldLoadDuckDBPackages } from "../utils";
13
14
 
14
15
  const MAKE_SNAPSHOT = false;
16
+ type SessionResources = [
17
+ bridge: PyProxy,
18
+ init: PyCallable,
19
+ packages: PyProxy,
20
+ stop: PyCallable,
21
+ ];
15
22
 
16
23
  // This class initializes the wasm environment
17
24
  // We would like this initialization to be parallelizable
@@ -25,6 +32,9 @@ const MAKE_SNAPSHOT = false;
25
32
 
26
33
  export class DefaultWasmController implements WasmController {
27
34
  protected pyodide: PyodideInterface | null = null;
35
+ private packageLoadQueue = Promise.resolve();
36
+ private sessionGeneration = 0;
37
+ private activeSessionStops = new Set<PyCallable>();
28
38
 
29
39
  get requirePyodide() {
30
40
  invariant(this.pyodide, "Pyodide not loaded");
@@ -111,6 +121,7 @@ export class DefaultWasmController implements WasmController {
111
121
  onMessage: (message: JsonString<NotificationPayload>) => void;
112
122
  userConfig: UserConfig;
113
123
  }): Promise<SerializedBridge> {
124
+ const sessionGeneration = this.sessionGeneration;
114
125
  const { code, filename, onMessage, queryParameters, userConfig } = opts;
115
126
  // We pass down a messenger object to the code
116
127
  // This is used to have synchronous communication between the JS and Python code
@@ -126,47 +137,129 @@ export class DefaultWasmController implements WasmController {
126
137
 
127
138
  const span = t.startSpan("startSession.runPython");
128
139
  const nbFilename = filename || WasmFileSystem.NOTEBOOK_FILENAME;
129
- const [bridge, init, packages] = this.requirePyodide.runPython(
140
+ const sessionResources = this.requirePyodide.runPython(
130
141
  `
131
142
  print("[py] Starting marimo...")
132
143
  import asyncio
144
+ import gc
133
145
  import js
134
146
  from marimo._pyodide.bootstrap import create_session, instantiate
135
147
 
136
148
  assert js.messenger, "messenger is not defined"
137
149
  assert js.query_params, "query_params is not defined"
138
150
 
139
- session, bridge = create_session(
140
- filename="${nbFilename}",
141
- query_params=js.query_params.to_py(),
142
- message_callback=js.messenger.callback,
143
- user_config=js.user_config.to_py(),
144
- )
151
+ def create_session_resources():
152
+ session, bridge = create_session(
153
+ filename="${nbFilename}",
154
+ query_params=js.query_params.to_py(),
155
+ message_callback=js.messenger.callback,
156
+ user_config=js.user_config.to_py(),
157
+ )
158
+ session_task = None
145
159
 
146
- def init(auto_instantiate=True):
147
- instantiate(session, auto_instantiate)
148
- asyncio.create_task(session.start())
160
+ def init(auto_instantiate=True):
161
+ nonlocal session_task
162
+ instantiate(session, auto_instantiate)
163
+ session_task = asyncio.create_task(session.start())
149
164
 
150
- # Find the packages to install
151
- with open("${nbFilename}", "r") as f:
152
- packages = session.find_packages(f.read())
165
+ async def stop():
166
+ nonlocal bridge, session, session_task
167
+ task = session_task
168
+ kernel_task = getattr(session, "kernel_task", None)
169
+ if kernel_task is None:
170
+ if task is not None:
171
+ task.cancel()
172
+ else:
173
+ kernel_task.stop()
174
+ if task is not None:
175
+ await asyncio.gather(task, return_exceptions=True)
176
+ if bridge is not None:
177
+ bridge.session = None
178
+ task = None
179
+ kernel_task = None
180
+ session_task = None
181
+ session = None
182
+ bridge = None
183
+ gc.collect()
153
184
 
154
- bridge, init, packages`,
155
- );
156
- span.end();
185
+ with open("${nbFilename}", "r") as f:
186
+ packages = session.find_packages(f.read())
157
187
 
158
- const foundPackages = new Set<string>(packages.toJs());
188
+ return bridge, init, packages, stop
189
+
190
+ create_session_resources()`,
191
+ ) as PyProxy;
192
+ span.end();
193
+ let bridgeProxy!: PyProxy;
194
+ let initSession!: PyCallable;
195
+ let packagesProxy!: PyProxy;
196
+ let stopSession!: PyCallable;
197
+ try {
198
+ [bridgeProxy, initSession, packagesProxy, stopSession] =
199
+ sessionResources as unknown as SessionResources;
200
+ } finally {
201
+ sessionResources.destroy();
202
+ }
203
+ let foundPackages: Set<string>;
204
+ try {
205
+ foundPackages = new Set<string>(packagesProxy.toJs());
206
+ } catch (error) {
207
+ bridgeProxy.destroy();
208
+ initSession.destroy();
209
+ stopSession.destroy();
210
+ throw error;
211
+ } finally {
212
+ packagesProxy.destroy();
213
+ }
214
+ this.activeSessionStops.add(stopSession);
159
215
 
160
216
  // Fire and forget:
161
217
  // Load notebook dependencies and instantiate the session
162
218
  // We don't want to wait for this to finish,
163
219
  // so we can show the initial code immediately giving
164
220
  // a sense of responsiveness.
165
- void this.loadNotebookDeps(code, foundPackages).then(() => {
166
- return init(userConfig.runtime.auto_instantiate);
221
+ const dependenciesReady = this.packageLoadQueue.then(() => {
222
+ if (sessionGeneration !== this.sessionGeneration) {
223
+ return;
224
+ }
225
+ return this.loadNotebookDeps(code, foundPackages);
167
226
  });
227
+ this.packageLoadQueue = dependenciesReady.catch(() => undefined);
228
+ void dependenciesReady
229
+ .then(() => {
230
+ if (sessionGeneration !== this.sessionGeneration) {
231
+ return;
232
+ }
233
+ return initSession(userConfig.runtime.auto_instantiate);
234
+ })
235
+ .catch((error: unknown) => {
236
+ Logger.error("Failed to load notebook dependencies", error);
237
+ })
238
+ .finally(() => initSession.destroy());
239
+
240
+ return bridgeProxy as unknown as SerializedBridge;
241
+ }
168
242
 
169
- return bridge;
243
+ async stopSession(): Promise<void> {
244
+ this.sessionGeneration += 1;
245
+ const stops = [...this.activeSessionStops];
246
+ let hasFailure = false;
247
+ let firstFailure: unknown;
248
+ for (const stop of stops) {
249
+ try {
250
+ await stop();
251
+ this.activeSessionStops.delete(stop);
252
+ stop.destroy();
253
+ } catch (error) {
254
+ if (!hasFailure) {
255
+ hasFailure = true;
256
+ firstFailure = error;
257
+ }
258
+ }
259
+ }
260
+ if (hasFailure) {
261
+ throw firstFailure;
262
+ }
170
263
  }
171
264
 
172
265
  private async loadNotebookDeps(code: string, foundPackages: Set<string>) {
@@ -255,6 +255,12 @@
255
255
  border: none;
256
256
  box-shadow: none;
257
257
 
258
+ /* No dividers between sections. Tailwind v4's divide-y is structural
259
+ * (`> :not(:last-child)`), so hidden-but-mounted children (e.g. the tray
260
+ * while presenting) still count as siblings and would otherwise draw a
261
+ * stray rule under the output. */
262
+ @apply divide-y-0;
263
+
258
264
  .output-area {
259
265
  /* flow-root interferes with margin collapsing, but appears to be unneeded
260
266
  * when cell outlines are hidden; clear:both is sufficient.
@@ -443,6 +449,10 @@
443
449
  /* Set a fixed gutter width to ensure the hover area is always wide enough */
444
450
  /* And make the gutter bigger when the window is super wide */
445
451
  --gutter-width: 100px;
452
+
453
+ /* Vertical gap between notebook cells (gap-5). Overridable via custom css;
454
+ * zeroed while presenting, when published cells flow together. */
455
+ --notebook-cell-gap: 1.25rem;
446
456
  }
447
457
 
448
458
  /* ------------------------ CodeMirror Editor ----------------------------- */
@@ -0,0 +1,88 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { renderHook } from "@testing-library/react";
3
+ import { createStore, Provider } from "jotai";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { Functions } from "@/utils/functions";
6
+ import { useHotkey } from "../useHotkey";
7
+
8
+ // global.hideCode is bound to "Mod-." by default; "mod" accepts ctrl or meta.
9
+ const SHORTCUT = "global.hideCode";
10
+
11
+ function createWrapper() {
12
+ const store = createStore();
13
+ return ({ children }: { children: React.ReactNode }) => (
14
+ <Provider store={store}>{children}</Provider>
15
+ );
16
+ }
17
+
18
+ function press() {
19
+ const event = new KeyboardEvent("keydown", {
20
+ key: ".",
21
+ ctrlKey: true,
22
+ cancelable: true,
23
+ bubbles: true,
24
+ });
25
+ document.dispatchEvent(event);
26
+ return event;
27
+ }
28
+
29
+ describe("useHotkey", () => {
30
+ it("invokes the callback and prevents default", () => {
31
+ const callback = vi.fn();
32
+ renderHook(() => useHotkey(SHORTCUT, callback), {
33
+ wrapper: createWrapper(),
34
+ });
35
+
36
+ const event = press();
37
+ expect(callback).toHaveBeenCalledOnce();
38
+ expect(event.defaultPrevented).toBe(true);
39
+ });
40
+
41
+ it("does not prevent default when the callback returns false", () => {
42
+ const callback = vi.fn(() => false);
43
+ renderHook(() => useHotkey(SHORTCUT, callback), {
44
+ wrapper: createWrapper(),
45
+ });
46
+
47
+ const event = press();
48
+ expect(callback).toHaveBeenCalledOnce();
49
+ expect(event.defaultPrevented).toBe(false);
50
+ });
51
+
52
+ it("still swallows the keystroke for NOOP catch-all handlers", () => {
53
+ renderHook(() => useHotkey(SHORTCUT, Functions.NOOP), {
54
+ wrapper: createWrapper(),
55
+ });
56
+
57
+ const event = press();
58
+ expect(event.defaultPrevented).toBe(true);
59
+ });
60
+
61
+ it("neither runs the callback nor prevents default when disabled", () => {
62
+ const callback = vi.fn();
63
+ renderHook(() => useHotkey(SHORTCUT, callback, { disabled: true }), {
64
+ wrapper: createWrapper(),
65
+ });
66
+
67
+ const event = press();
68
+ expect(callback).not.toHaveBeenCalled();
69
+ expect(event.defaultPrevented).toBe(false);
70
+ });
71
+
72
+ it("re-enables when disabled flips back to false", () => {
73
+ const callback = vi.fn();
74
+ const { rerender } = renderHook(
75
+ ({ disabled }: { disabled: boolean }) =>
76
+ useHotkey(SHORTCUT, callback, { disabled }),
77
+ { wrapper: createWrapper(), initialProps: { disabled: true } },
78
+ );
79
+
80
+ press();
81
+ expect(callback).not.toHaveBeenCalled();
82
+
83
+ rerender({ disabled: false });
84
+ const event = press();
85
+ expect(callback).toHaveBeenCalledOnce();
86
+ expect(event.defaultPrevented).toBe(true);
87
+ });
88
+ });
@@ -17,13 +17,31 @@ type HotkeyHandler = (
17
17
  // oxlint-disable-next-line typescript/no-invalid-void-type
18
18
  ) => boolean | void | undefined | Promise<void>;
19
19
 
20
+ interface HotkeyOptions {
21
+ /**
22
+ * If true, the hotkey is not handled at all: the callback does not run, the
23
+ * event's default is not prevented, and the action is not registered in the
24
+ * shortcut registry.
25
+ *
26
+ * This differs from passing `Functions.NOOP` as the callback, which still
27
+ * swallows the keystroke (preventDefault) to suppress native browser/OS
28
+ * behavior.
29
+ */
30
+ disabled?: boolean;
31
+ }
32
+
20
33
  /**
21
34
  * Registers a hotkey listener for the given shortcut.
22
35
  *
23
36
  * @param callback The callback to run when the shortcut is pressed. It does not need to be memoized as this hook will always use the latest callback.
24
37
  * If the callback returns false, preventDefault will not be called on the event.
25
38
  */
26
- export function useHotkey(shortcut: HotkeyAction, callback: HotkeyHandler) {
39
+ export function useHotkey(
40
+ shortcut: HotkeyAction,
41
+ callback: HotkeyHandler,
42
+ options: HotkeyOptions = {},
43
+ ) {
44
+ const { disabled = false } = options;
27
45
  const { registerAction, unregisterAction } = useSetRegisteredAction();
28
46
  const hotkeys = useAtomValue(hotkeysAtom);
29
47
 
@@ -31,7 +49,7 @@ export function useHotkey(shortcut: HotkeyAction, callback: HotkeyHandler) {
31
49
  const memoizeCallback = useEvent((evt?: KeyboardEvent) => callback(evt));
32
50
 
33
51
  const listener = useEvent((e: KeyboardEvent) => {
34
- if (e.defaultPrevented) {
52
+ if (disabled || e.defaultPrevented) {
35
53
  return;
36
54
  }
37
55
 
@@ -51,11 +69,18 @@ export function useHotkey(shortcut: HotkeyAction, callback: HotkeyHandler) {
51
69
 
52
70
  // Register with the shortcut registry
53
71
  useEffect(() => {
54
- if (!isNOOP) {
72
+ if (!isNOOP && !disabled) {
55
73
  registerAction(shortcut, memoizeCallback);
56
74
  return () => unregisterAction(shortcut);
57
75
  }
58
- }, [memoizeCallback, shortcut, isNOOP, registerAction, unregisterAction]);
76
+ }, [
77
+ memoizeCallback,
78
+ shortcut,
79
+ isNOOP,
80
+ disabled,
81
+ registerAction,
82
+ unregisterAction,
83
+ ]);
59
84
  }
60
85
 
61
86
  /**
@@ -277,25 +277,22 @@ describe("WidgetBinding receives a host in render props", () => {
277
277
  });
278
278
  });
279
279
 
280
- describe("host-mounted views hydrate like display-mounted ones", () => {
281
- it("replays current state to a child's render listeners", async () => {
282
- const childId = asModelId("host-hydration-child");
280
+ describe("host-mounted views", () => {
281
+ it("render with the child's current model state", async () => {
282
+ const childId = asModelId("host-child-state");
283
283
  const parentController = new AbortController();
284
284
  const getModuleSpy = vi.spyOn(WIDGET_DEF_REGISTRY, "getModule");
285
285
  try {
286
286
  const childWidget = {
287
287
  render: vi.fn(({ model, el }) => {
288
- el.textContent = "count is 5";
289
- model.on("change:count", () => {
290
- el.textContent = `count is ${model.get("count")}`;
291
- });
288
+ el.textContent = `count is ${model.get("count")}`;
292
289
  }),
293
290
  };
294
291
  const childModel = new Model<ModelState>({ count: 8 }, createMockComm());
295
292
  WIDGET_REGISTRY.setModel(childId, childModel);
296
293
  WIDGET_REGISTRY.setSpec(childId, {
297
- url: "./@file/10-host-hydration-child.js",
298
- hash: "hash-host-hydration-child",
294
+ url: "./@file/10-host-child-state.js",
295
+ hash: "hash-host-child-state",
299
296
  });
300
297
  getModuleSpy.mockResolvedValue({ default: childWidget });
301
298
 
@@ -337,13 +337,12 @@ describe("WidgetBinding", () => {
337
337
  { el: document.createElement("div") },
338
338
  { signal: controller.signal },
339
339
  );
340
- // Called once by the hydration replay at mount, once by the set.
341
340
  model.set("count", 1);
342
- expect(onCount).toHaveBeenCalledTimes(2);
341
+ expect(onCount).toHaveBeenCalledTimes(1);
343
342
 
344
343
  controller.abort();
345
344
  model.set("count", 2);
346
- expect(onCount).toHaveBeenCalledTimes(2);
345
+ expect(onCount).toHaveBeenCalledTimes(1);
347
346
  });
348
347
 
349
348
  it("should auto-clear initialize listeners on destroy", async () => {
@@ -366,92 +365,54 @@ describe("WidgetBinding", () => {
366
365
  });
367
366
  });
368
367
 
369
- describe("WidgetBinding hydration replay", () => {
370
- it("replays current state to render listeners exactly once", async () => {
368
+ describe("WidgetBinding render listeners", () => {
369
+ it("fire only for changes after mount, never for pre-existing state", async () => {
371
370
  const model = new Model<ModelState>({ count: 8 }, createMockComm());
372
- const el = document.createElement("div");
373
- const widgetDef = {
374
- render: vi.fn(({ model, el }) => {
375
- // A widget view that starts with a local default and relies on
376
- // change events for hydration.
377
- el.textContent = "count is 5";
378
- model.on("change:count", () => {
379
- el.textContent = `count is ${model.get("count")}`;
380
- });
381
- }),
382
- };
383
- const binding = await createBinding(widgetDef, model);
384
-
385
- const controller = new AbortController();
386
- await binding.createView({ el }, { signal: controller.signal });
387
- expect(el.textContent).toBe("count is 8");
388
- });
389
-
390
- it("does not re-fire listeners of already-mounted views", async () => {
391
- // Mounting view B must not double-paint view A: the replay is
392
- // scoped to the listeners the new render attached.
393
- const model = new Model<ModelState>({ count: 0 }, createMockComm());
394
- const listeners: Array<ReturnType<typeof vi.fn>> = [];
371
+ const onCount = vi.fn();
372
+ const onAnyChange = vi.fn();
395
373
  const widgetDef = {
396
374
  render: vi.fn(({ model }) => {
397
- const listener = vi.fn();
398
- listeners.push(listener);
399
- model.on("change:count", listener);
375
+ model.on("change:count", onCount);
376
+ model.on("change", onAnyChange);
400
377
  }),
401
378
  };
402
379
  const binding = await createBinding(widgetDef, model);
403
380
  const controller = new AbortController();
404
-
405
381
  await binding.createView(
406
382
  { el: document.createElement("div") },
407
383
  { signal: controller.signal },
408
384
  );
409
- expect(listeners[0]).toHaveBeenCalledTimes(1);
385
+ expect(onCount).not.toHaveBeenCalled();
386
+ expect(onAnyChange).not.toHaveBeenCalled();
410
387
 
411
- await binding.createView(
412
- { el: document.createElement("div") },
413
- { signal: controller.signal },
414
- );
415
- // View B's listener hydrated once; view A's was left alone.
416
- expect(listeners[1]).toHaveBeenCalledTimes(1);
417
- expect(listeners[0]).toHaveBeenCalledTimes(1);
388
+ model.set("count", 9);
389
+ expect(onCount).toHaveBeenCalledTimes(1);
390
+ expect(onCount).toHaveBeenCalledWith(9);
418
391
  });
419
392
 
420
- it("replays the any-change event to its listeners", async () => {
421
- const model = new Model<ModelState>({ count: 1 }, createMockComm());
422
- const onAnyChange = vi.fn();
393
+ it("mounting a second view does not fire the first view's listeners", async () => {
394
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
395
+ const listeners: Array<ReturnType<typeof vi.fn>> = [];
423
396
  const widgetDef = {
424
397
  render: vi.fn(({ model }) => {
425
- model.on("change", onAnyChange);
398
+ const listener = vi.fn();
399
+ listeners.push(listener);
400
+ model.on("change:count", listener);
426
401
  }),
427
402
  };
428
403
  const binding = await createBinding(widgetDef, model);
429
404
  const controller = new AbortController();
405
+
430
406
  await binding.createView(
431
407
  { el: document.createElement("div") },
432
408
  { signal: controller.signal },
433
409
  );
434
- expect(onAnyChange).toHaveBeenCalledTimes(1);
435
- });
436
-
437
- it("does not replay initialize listeners", async () => {
438
- // The guarantee is per-view: initialize listeners existed before
439
- // any view, and replaying at them would fire once per mount.
440
- const model = new Model<ModelState>({ count: 1 }, createMockComm());
441
- const initListener = vi.fn();
442
- const widgetDef = {
443
- initialize: vi.fn(({ model }) => {
444
- model.on("change:count", initListener);
445
- }),
446
- render: vi.fn(),
447
- };
448
- const binding = await createBinding(widgetDef, model);
449
- const controller = new AbortController();
450
410
  await binding.createView(
451
411
  { el: document.createElement("div") },
452
412
  { signal: controller.signal },
453
413
  );
454
- expect(initListener).not.toHaveBeenCalled();
414
+ expect(listeners[0]).not.toHaveBeenCalled();
415
+ expect(listeners[1]).not.toHaveBeenCalled();
455
416
  });
456
417
 
457
418
  it("clears the element before rendering into it", async () => {
@@ -5,15 +5,6 @@ import type { ModelState } from "./types";
5
5
 
6
6
  type ModelEventCallback = Parameters<AnyModel<ModelState>["on"]>[1];
7
7
 
8
- /**
9
- * A listener registered through a model proxy (see the hydration
10
- * replay in `WidgetBinding.createView`).
11
- */
12
- export interface ProxyRegistration {
13
- event: string;
14
- callback: ModelEventCallback;
15
- }
16
-
17
8
  /**
18
9
  * Wrap a model so every `on()` call from inside `initialize` or `render`
19
10
  * is auto-tied to a lifetime `AbortSignal`. When the signal aborts,
@@ -27,14 +18,11 @@ export interface ProxyRegistration {
27
18
  * The proxy is purely a host-side ergonomic helper. The widget author
28
19
  * still writes `model.on("change:foo", handler)` exactly as before; the
29
20
  * cleanup signal is supplied transparently.
30
- *
31
- * `onRegister`, when given, observes each `on()` registration.
32
21
  */
33
22
  // oxlint-disable-next-line marimo/prefer-object-params -- concise internal helper used at protocol call sites
34
23
  export function modelProxy<T extends ModelState>(
35
24
  model: AnyModel<T>,
36
25
  signal: AbortSignal,
37
- onRegister?: (registration: ProxyRegistration) => void,
38
26
  ): AnyModel<T> {
39
27
  return {
40
28
  get(key) {
@@ -55,7 +43,6 @@ export function modelProxy<T extends ModelState>(
55
43
  signal.addEventListener("abort", () => model.off(name, callback), {
56
44
  once: true,
57
45
  });
58
- onRegister?.({ event: name, callback });
59
46
  },
60
47
  off(name?: string | null, callback?: ModelEventCallback | null): void {
61
48
  model.off(name ?? null, callback ?? null);
@@ -12,7 +12,7 @@ import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
12
12
  import { Logger } from "@/utils/Logger";
13
13
  import type { Host } from "./host";
14
14
  import type { Model } from "./model";
15
- import { modelProxy, type ProxyRegistration } from "./model-proxy";
15
+ import { modelProxy } from "./model-proxy";
16
16
  import type { ModelState } from "./types";
17
17
 
18
18
  export const experimental: Experimental = {
@@ -266,9 +266,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
266
266
  * when the caller's `signal` fires or the binding is destroyed, and
267
267
  * listeners registered inside `render` auto-clear on abort.
268
268
  *
269
- * Hydration guarantee: listeners attached during `render` observe
270
- * current model state exactly once, after `render` settles. Scoped
271
- * to this view's listeners; re-firing at other views double-paints.
269
+ * `render` reads current state via `model.get`; change listeners
270
+ * observe only subsequent changes, matching Jupyter semantics.
272
271
  */
273
272
  async createView(
274
273
  target: { el: HTMLElement },
@@ -305,11 +304,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
305
304
  // Each view gets a host scoped to its own signal so child views tear
306
305
  // down with this view.
307
306
  const host = this.#createHost(renderSignal);
308
- const registrations: ProxyRegistration[] = [];
309
307
  const renderCleanup = await widget.render({
310
- model: modelProxy(this.#model, renderSignal, (registration) =>
311
- registrations.push(registration),
312
- ),
308
+ model: modelProxy(this.#model, renderSignal),
313
309
  el: target.el,
314
310
  experimental,
315
311
  signal: renderSignal,
@@ -333,7 +329,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
333
329
  once: true,
334
330
  });
335
331
  }
336
- this.#replayState(registrations, renderSignal);
337
332
  }
338
333
 
339
334
  #trackCleanup(cleanup: Cleanup, reason: string): Promise<void> {
@@ -343,31 +338,6 @@ export class WidgetBinding<T extends ModelState = ModelState> {
343
338
  return task;
344
339
  }
345
340
 
346
- /**
347
- * The hydration guarantee documented on `createView`.
348
- */
349
- #replayState(
350
- registrations: readonly ProxyRegistration[],
351
- renderSignal: AbortSignal,
352
- ): void {
353
- const changePrefix = "change:";
354
- for (const { event, callback } of registrations) {
355
- if (renderSignal.aborted) {
356
- return;
357
- }
358
- try {
359
- if (event.startsWith(changePrefix)) {
360
- const key = event.slice(changePrefix.length);
361
- callback(this.#model.get(key));
362
- } else if (event === "change") {
363
- callback();
364
- }
365
- } catch (error) {
366
- Logger.error("[WidgetBinding] Error replaying state", error);
367
- }
368
- }
369
- }
370
-
371
341
  /**
372
342
  * Destroy this generation, running initialize/render cleanups and
373
343
  * clearing listeners registered through its model proxies.