@marimo-team/islands 0.23.14-dev6 → 0.23.14-dev61

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 (171) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
  2. package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
  3. package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
  4. package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
  5. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  6. package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
  7. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  8. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  9. package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
  10. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
  11. package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
  12. package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
  13. package/dist/main.js +3630 -1801
  14. package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
  15. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  16. package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
  17. package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
  18. package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
  19. package/dist/style.css +1 -1
  20. package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
  21. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
  22. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
  23. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
  24. package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
  25. package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
  26. package/package.json +4 -4
  27. package/src/__mocks__/requests.ts +1 -0
  28. package/src/__tests__/CellStatus.test.tsx +1 -2
  29. package/src/components/app-config/user-config-form.tsx +52 -0
  30. package/src/components/chat/acp/agent-panel.tsx +35 -1
  31. package/src/components/chat/chat-panel.tsx +68 -29
  32. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  33. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  34. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  35. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  36. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  37. package/src/components/data-table/charts/charts.tsx +23 -1
  38. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  39. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  40. package/src/components/debugger/debugger-code.tsx +7 -2
  41. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  42. package/src/components/editor/cell/CellStatus.tsx +1 -20
  43. package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
  44. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  45. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  46. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  47. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  48. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  49. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  50. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  51. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  52. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  53. package/src/components/editor/errors/auto-fix.tsx +88 -34
  54. package/src/components/editor/errors/fix-mode.ts +1 -1
  55. package/src/components/editor/notebook-cell.tsx +7 -0
  56. package/src/components/editor/output/ImageOutput.tsx +20 -6
  57. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
  58. package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
  59. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  60. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  61. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  62. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  63. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  64. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  65. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  66. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  67. package/src/components/slides/minimap.tsx +7 -1
  68. package/src/components/slides/reveal-component.tsx +3 -0
  69. package/src/components/slides/slide-cell-view.tsx +10 -6
  70. package/src/components/slides/slide.tsx +16 -13
  71. package/src/components/tracing/tracing.tsx +2 -1
  72. package/src/core/ai/config.ts +2 -2
  73. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  74. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  75. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  76. package/src/core/ai/context/providers/datasource.ts +27 -2
  77. package/src/core/ai/context/providers/error.ts +226 -36
  78. package/src/core/ai/context/registry.ts +77 -43
  79. package/src/core/ai/state.ts +11 -0
  80. package/src/core/cells/__tests__/cell.test.ts +39 -0
  81. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  82. package/src/core/cells/cell.ts +5 -3
  83. package/src/core/cells/readonly-code-display.ts +35 -0
  84. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  85. package/src/core/codemirror/ai/resources.ts +15 -10
  86. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  87. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
  88. package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
  89. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  90. package/src/core/codemirror/cells/debugger-state.ts +133 -0
  91. package/src/core/codemirror/cells/extensions.ts +32 -4
  92. package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
  93. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  94. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  95. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  96. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  97. package/src/core/codemirror/utils.ts +15 -0
  98. package/src/core/config/__tests__/config-schema.test.ts +17 -0
  99. package/src/core/config/config-schema.ts +1 -0
  100. package/src/core/config/config.ts +10 -0
  101. package/src/core/config/feature-flag.tsx +4 -0
  102. package/src/core/edit-app.tsx +8 -5
  103. package/src/core/errors/errors.ts +2 -1
  104. package/src/core/islands/bootstrap.ts +4 -3
  105. package/src/core/islands/bridge.ts +1 -0
  106. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  107. package/src/core/lifecycle/render-policy.ts +125 -0
  108. package/src/core/mime.ts +11 -4
  109. package/src/core/network/__tests__/api.test.ts +17 -0
  110. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  111. package/src/core/network/api.ts +17 -6
  112. package/src/core/network/requests-lazy.ts +1 -0
  113. package/src/core/network/requests-network.ts +8 -0
  114. package/src/core/network/requests-static.ts +1 -0
  115. package/src/core/network/requests-toasting.tsx +10 -1
  116. package/src/core/network/types.ts +2 -0
  117. package/src/core/run-app.tsx +27 -23
  118. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  119. package/src/core/runtime/__tests__/runtime.test.ts +45 -0
  120. package/src/core/runtime/adapter.ts +182 -0
  121. package/src/core/runtime/runtime.ts +30 -16
  122. package/src/core/wasm/PyodideLoader.tsx +20 -62
  123. package/src/core/wasm/bridge.ts +13 -4
  124. package/src/core/wasm/state.ts +8 -19
  125. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
  126. package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
  127. package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
  128. package/src/core/websocket/transports/sse.ts +308 -0
  129. package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
  130. package/src/core/websocket/useWebSocket.tsx +12 -2
  131. package/src/css/app/codemirror.css +14 -0
  132. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  133. package/src/hooks/useDelayElapsed.ts +27 -0
  134. package/src/mount.tsx +3 -3
  135. package/src/plugins/core/RenderHTML.tsx +35 -1
  136. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  137. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  138. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
  139. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
  140. package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
  141. package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
  142. package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
  143. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
  144. package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
  145. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
  146. package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
  147. package/src/plugins/impl/anywidget/host.ts +54 -0
  148. package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
  149. package/src/plugins/impl/anywidget/model.ts +59 -239
  150. package/src/plugins/impl/anywidget/registry.ts +268 -0
  151. package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
  152. package/src/plugins/impl/anywidget/runtime.ts +422 -0
  153. package/src/plugins/impl/anywidget/types.ts +14 -0
  154. package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
  155. package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
  156. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  157. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  158. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  159. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
  160. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
  161. package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
  162. package/src/utils/errors.ts +15 -0
  163. package/src/utils/time.ts +20 -0
  164. package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
  165. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  166. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  167. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  168. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  169. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  170. package/src/core/wasm/__tests__/state.test.ts +0 -124
  171. package/src/plugins/impl/anywidget/schemas.ts +0 -32
@@ -1,19 +1,18 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import type { ExtractAtomValue } from "jotai";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { AnyWidget } from "@anywidget/types";
4
5
  import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
5
6
  import { userConfigAtom } from "@/core/config/config";
6
7
  import { parseUserConfig } from "@/core/config/config-schema";
7
8
  import { initialModeAtom } from "@/core/mode";
8
9
  import { store } from "@/core/state/jotai";
9
10
  import { Model } from "../model";
10
- import type { ModelState, WidgetModelId } from "../types";
11
+ import type { Host } from "../host";
12
+ import type { ModelState } from "../types";
11
13
  import { visibleForTesting } from "../widget-binding";
12
14
 
13
- const { WidgetDefRegistry, WidgetBinding, BindingManager } = visibleForTesting;
14
-
15
- // Helper to create typed model IDs for tests
16
- const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
15
+ const { WidgetDefRegistry, WidgetBinding } = visibleForTesting;
17
16
 
18
17
  function createMockComm() {
19
18
  return {
@@ -22,6 +21,34 @@ function createMockComm() {
22
21
  };
23
22
  }
24
23
 
24
+ const createTestHost = (): Host => ({
25
+ getModel: vi.fn(),
26
+ getWidget: vi.fn(),
27
+ });
28
+
29
+ // oxlint-disable-next-line marimo/prefer-object-params -- terse test helper
30
+ function createBinding<T extends ModelState>(
31
+ widgetDef: AnyWidget<T>,
32
+ model: Model<T>,
33
+ options: { controller?: AbortController } = {},
34
+ ) {
35
+ return WidgetBinding.create({
36
+ widgetDef,
37
+ model,
38
+ createHost: createTestHost,
39
+ controller: options.controller,
40
+ });
41
+ }
42
+
43
+ // oxlint-disable-next-line marimo/prefer-object-params -- terse test helper
44
+ function getModule(
45
+ registry: InstanceType<typeof WidgetDefRegistry>,
46
+ jsUrl: string,
47
+ jsHash: string,
48
+ ) {
49
+ return registry.getModule({ jsUrl, jsHash });
50
+ }
51
+
25
52
  describe("WidgetDefRegistry", () => {
26
53
  let registry: InstanceType<typeof WidgetDefRegistry>;
27
54
  let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
@@ -53,44 +80,35 @@ describe("WidgetDefRegistry", () => {
53
80
 
54
81
  it("should cache modules by jsHash and return same promise", () => {
55
82
  // Two calls with same hash should return the exact same promise object
56
- const promise1 = registry.getModule("http://localhost/widget.js", "hash1");
57
- const promise2 = registry.getModule("http://localhost/widget.js", "hash1");
83
+ const promise1 = getModule(registry, "http://localhost/widget.js", "hash1");
84
+ const promise2 = getModule(registry, "http://localhost/widget.js", "hash1");
58
85
  expect(promise1).toBe(promise2);
59
86
  // Catch the unhandled rejection from the import() attempt
60
87
  promise1.catch(() => undefined);
61
88
  });
62
89
 
63
90
  it("should deduplicate concurrent imports for the same hash", () => {
64
- const promise1 = registry.getModule("http://localhost/a.js", "same-hash");
65
- const promise2 = registry.getModule("http://localhost/b.js", "same-hash");
91
+ const promise1 = getModule(registry, "http://localhost/a.js", "same-hash");
92
+ const promise2 = getModule(registry, "http://localhost/b.js", "same-hash");
66
93
  // Same hash means same promise, even with different URLs
67
94
  expect(promise1).toBe(promise2);
68
95
  promise1.catch(() => undefined);
69
96
  });
70
97
 
71
98
  it("should create different promises for different hashes", () => {
72
- const promise1 = registry.getModule("http://localhost/a.js", "hash-a");
73
- const promise2 = registry.getModule("http://localhost/b.js", "hash-b");
99
+ const promise1 = getModule(registry, "http://localhost/a.js", "hash-a");
100
+ const promise2 = getModule(registry, "http://localhost/b.js", "hash-b");
74
101
  expect(promise1).not.toBe(promise2);
75
102
  promise1.catch(() => undefined);
76
103
  promise2.catch(() => undefined);
77
104
  });
78
105
 
79
- it("should invalidate cached modules", () => {
80
- const promise1 = registry.getModule("http://localhost/a.js", "hash1");
81
- promise1.catch(() => undefined);
82
- registry.invalidate("hash1");
83
- const promise2 = registry.getModule("http://localhost/a.js", "hash1");
84
- promise2.catch(() => undefined);
85
- expect(promise1).not.toBe(promise2);
86
- });
87
-
88
106
  it("should remove from cache on import failure so retry creates new promise", async () => {
89
- const promise1 = registry.getModule("http://localhost/a.js", "fail-hash");
107
+ const promise1 = getModule(registry, "http://localhost/a.js", "fail-hash");
90
108
  // The URL is rejected by the trusted-URL validator.
91
109
  await expect(promise1).rejects.toThrow();
92
110
  // After failure, cache should be cleared, so next call creates a new promise
93
- const promise2 = registry.getModule("http://localhost/a.js", "fail-hash");
111
+ const promise2 = getModule(registry, "http://localhost/a.js", "fail-hash");
94
112
  expect(promise1).not.toBe(promise2);
95
113
  promise2.catch(() => undefined);
96
114
  });
@@ -106,7 +124,7 @@ describe("WidgetDefRegistry", () => {
106
124
  "./@file/x.js?redirect=http://evil.com",
107
125
  "",
108
126
  ])("rejects untrusted URL: %s", async (url) => {
109
- await expect(registry.getModule(url, `hash-${url}`)).rejects.toThrow(
127
+ await expect(getModule(registry, url, `hash-${url}`)).rejects.toThrow(
110
128
  /untrusted/i,
111
129
  );
112
130
  });
@@ -116,179 +134,333 @@ describe("WidgetDefRegistry", () => {
116
134
  // is a Node test environment with no server. We only assert that
117
135
  // the rejection reason is NOT the "untrusted URL" refusal.
118
136
  await expect(
119
- registry.getModule("./@file/123-widget.js", "trusted-hash"),
137
+ getModule(registry, "./@file/123-widget.js", "trusted-hash"),
120
138
  ).rejects.not.toThrow(/untrusted/i);
121
139
  });
122
140
  });
123
141
  });
124
142
 
125
143
  describe("WidgetBinding", () => {
126
- let binding: InstanceType<typeof WidgetBinding>;
127
- let model: Model<ModelState>;
128
-
129
- beforeEach(() => {
130
- binding = new WidgetBinding();
131
- model = new Model<ModelState>({ count: 0 }, createMockComm());
132
- });
133
-
134
- it("should initialize once and return a render function", async () => {
135
- const initCleanup = vi.fn();
136
- const renderCleanup = vi.fn();
144
+ it("should run initialize exactly once and expose exports", async () => {
145
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
146
+ const exports = { setValue: vi.fn() };
137
147
  const widgetDef = {
138
- initialize: vi.fn().mockResolvedValue(initCleanup),
139
- render: vi.fn().mockResolvedValue(renderCleanup),
148
+ initialize: vi.fn().mockResolvedValue(exports),
149
+ render: vi.fn(),
140
150
  };
141
151
 
142
- const renderFn = await binding.bind(widgetDef, model);
152
+ const binding = await createBinding(widgetDef, model);
143
153
  expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
144
- expect(typeof renderFn).toBe("function");
154
+ expect(binding.exports).toBe(exports);
145
155
 
146
- // Render into an element
147
- const el = document.createElement("div");
148
156
  const controller = new AbortController();
149
- await renderFn(el, controller.signal);
150
- expect(widgetDef.render).toHaveBeenCalledTimes(1);
157
+ await binding.createView(
158
+ { el: document.createElement("div") },
159
+ { signal: controller.signal },
160
+ );
161
+ await binding.createView(
162
+ { el: document.createElement("div") },
163
+ { signal: controller.signal },
164
+ );
165
+ expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
166
+ expect(widgetDef.render).toHaveBeenCalledTimes(2);
151
167
  });
152
168
 
153
- it("should return cached render for same widget def", async () => {
154
- const widgetDef = {
155
- initialize: vi.fn(),
156
- render: vi.fn(),
157
- };
169
+ it("should handle widget def as a factory function", async () => {
170
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
171
+ const render = vi.fn();
172
+ const factory = vi.fn().mockResolvedValue({ render });
158
173
 
159
- const render1 = await binding.bind(widgetDef, model);
160
- const render2 = await binding.bind(widgetDef, model);
161
- expect(render1).toBe(render2);
162
- // Initialize should only be called once
163
- expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
174
+ const binding = await createBinding(factory, model);
175
+ const controller = new AbortController();
176
+ await binding.createView(
177
+ { el: document.createElement("div") },
178
+ { signal: controller.signal },
179
+ );
180
+ expect(factory).toHaveBeenCalledTimes(1);
181
+ expect(render).toHaveBeenCalledTimes(1);
164
182
  });
165
183
 
166
- it("should re-initialize on hot reload (different widget def)", async () => {
167
- const cleanup1 = vi.fn();
168
- const widgetDef1 = {
169
- initialize: vi.fn().mockResolvedValue(cleanup1),
170
- render: vi.fn(),
171
- };
184
+ it("should handle a widget with no initialize or render", async () => {
185
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
186
+ const binding = await createBinding({}, model);
187
+ expect(binding.exports).toBeUndefined();
188
+ const controller = new AbortController();
189
+ // No render — createView is a no-op, not an error.
190
+ await binding.createView(
191
+ { el: document.createElement("div") },
192
+ { signal: controller.signal },
193
+ );
194
+ });
195
+
196
+ it("should expose undefined exports for void initialize", async () => {
197
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
198
+ const binding = await createBinding(
199
+ { initialize: vi.fn(), render: vi.fn() },
200
+ model,
201
+ );
202
+ expect(binding.exports).toBeUndefined();
203
+ });
204
+
205
+ it("should run a legacy initialize cleanup on destroy", async () => {
206
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
207
+ const initCleanup = vi.fn();
208
+ const binding = await createBinding(
209
+ { initialize: vi.fn().mockResolvedValue(initCleanup), render: vi.fn() },
210
+ model,
211
+ );
212
+ expect(binding.exports).toBeUndefined();
213
+ expect(initCleanup).not.toHaveBeenCalled();
214
+ binding.destroy();
215
+ expect(initCleanup).toHaveBeenCalledTimes(1);
216
+ });
172
217
 
173
- const widgetDef2 = {
174
- initialize: vi.fn(),
218
+ it("should reject and run cleanup when aborted mid-initialize", async () => {
219
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
220
+ const initCleanup = vi.fn();
221
+ let resolveInit!: (v: unknown) => void;
222
+ const widgetDef = {
223
+ initialize: vi.fn().mockReturnValue(
224
+ new Promise((r) => {
225
+ resolveInit = r;
226
+ }),
227
+ ),
175
228
  render: vi.fn(),
176
229
  };
177
230
 
178
- const render1 = await binding.bind(widgetDef1, model);
179
- const render2 = await binding.bind(widgetDef2, model);
231
+ const controller = new AbortController();
232
+ const pending = createBinding(widgetDef, model, { controller });
233
+ pending.catch(() => undefined);
234
+
235
+ controller.abort();
236
+ resolveInit(initCleanup);
237
+
238
+ await expect(pending).rejects.toThrow(/binding destroyed/);
239
+ expect(initCleanup).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("should pass an AbortSignal to initialize that aborts on destroy", async () => {
243
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
244
+ const widgetDef = { initialize: vi.fn(), render: vi.fn() };
245
+ const binding = await createBinding(widgetDef, model);
180
246
 
181
- expect(render1).not.toBe(render2);
182
- expect(cleanup1).toHaveBeenCalledTimes(1); // Old binding cleaned up
183
- expect(widgetDef2.initialize).toHaveBeenCalledTimes(1);
247
+ const initSignal = widgetDef.initialize.mock.calls[0][0]
248
+ .signal as AbortSignal;
249
+ expect(initSignal.aborted).toBe(false);
250
+ binding.destroy();
251
+ expect(initSignal.aborted).toBe(true);
184
252
  });
185
253
 
186
- it("should cleanup render on view signal abort", async () => {
254
+ it("should abort a view when its own signal aborts", async () => {
255
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
187
256
  const renderCleanup = vi.fn();
188
257
  const widgetDef = {
189
- initialize: vi.fn(),
190
258
  render: vi.fn().mockResolvedValue(renderCleanup),
191
259
  };
260
+ const binding = await createBinding(widgetDef, model);
192
261
 
193
- const renderFn = await binding.bind(widgetDef, model);
194
- const el = document.createElement("div");
195
- const viewController = new AbortController();
196
- await renderFn(el, viewController.signal);
262
+ const controller = new AbortController();
263
+ await binding.createView(
264
+ { el: document.createElement("div") },
265
+ { signal: controller.signal },
266
+ );
267
+ const renderSignal = widgetDef.render.mock.calls[0][0]
268
+ .signal as AbortSignal;
269
+ expect(renderSignal.aborted).toBe(false);
270
+
271
+ controller.abort();
272
+ expect(renderSignal.aborted).toBe(true);
273
+ expect(renderCleanup).toHaveBeenCalledTimes(1);
274
+ });
275
+
276
+ it("runs a late render cleanup after the view already aborted", async () => {
277
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
278
+ const renderCleanup = vi.fn();
279
+ let resolveRender!: (cleanup: () => void) => void;
280
+ const widgetDef = {
281
+ render: vi.fn(
282
+ () =>
283
+ new Promise<() => void>((resolve) => {
284
+ resolveRender = resolve;
285
+ }),
286
+ ),
287
+ };
288
+ const binding = await createBinding(widgetDef, model);
289
+ const controller = new AbortController();
290
+
291
+ const pending = binding.createView(
292
+ { el: document.createElement("div") },
293
+ { signal: controller.signal },
294
+ );
295
+ controller.abort();
296
+ resolveRender(renderCleanup);
197
297
 
198
- // Aborting the view signal should trigger render cleanup
199
- viewController.abort();
298
+ await pending;
200
299
  expect(renderCleanup).toHaveBeenCalledTimes(1);
201
300
  });
202
301
 
203
- it("should cleanup everything on destroy", async () => {
204
- const initCleanup = vi.fn();
302
+ it("should abort every view when the binding is destroyed", async () => {
303
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
205
304
  const renderCleanup = vi.fn();
206
305
  const widgetDef = {
207
- initialize: vi.fn().mockResolvedValue(initCleanup),
208
306
  render: vi.fn().mockResolvedValue(renderCleanup),
209
307
  };
308
+ const binding = await createBinding(widgetDef, model);
210
309
 
211
- const renderFn = await binding.bind(widgetDef, model);
212
- const el = document.createElement("div");
213
- const viewController = new AbortController();
214
- await renderFn(el, viewController.signal);
310
+ const controller = new AbortController();
311
+ await binding.createView(
312
+ { el: document.createElement("div") },
313
+ { signal: controller.signal },
314
+ );
315
+ const renderSignal = widgetDef.render.mock.calls[0][0]
316
+ .signal as AbortSignal;
215
317
 
216
318
  binding.destroy();
217
- expect(initCleanup).toHaveBeenCalledTimes(1);
319
+ expect(renderSignal.aborted).toBe(true);
218
320
  expect(renderCleanup).toHaveBeenCalledTimes(1);
321
+ // The caller's own signal is untouched — only the combined one.
322
+ expect(controller.signal.aborted).toBe(false);
219
323
  });
220
324
 
221
- it("should handle widget def as a function", async () => {
222
- const widget = {
223
- initialize: vi.fn(),
224
- render: vi.fn(),
325
+ it("should auto-clear render listeners when the view aborts", async () => {
326
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
327
+ const onCount = vi.fn();
328
+ const widgetDef = {
329
+ render: vi.fn(({ model }) => {
330
+ model.on("change:count", onCount);
331
+ }),
225
332
  };
226
- const widgetDefFn = vi.fn().mockResolvedValue(widget);
333
+ const binding = await createBinding(widgetDef, model);
227
334
 
228
- await binding.bind(widgetDefFn, model);
229
- expect(widgetDefFn).toHaveBeenCalledTimes(1);
230
- expect(widget.initialize).toHaveBeenCalledTimes(1);
335
+ const controller = new AbortController();
336
+ await binding.createView(
337
+ { el: document.createElement("div") },
338
+ { signal: controller.signal },
339
+ );
340
+ // Called once by the hydration replay at mount, once by the set.
341
+ model.set("count", 1);
342
+ expect(onCount).toHaveBeenCalledTimes(2);
343
+
344
+ controller.abort();
345
+ model.set("count", 2);
346
+ expect(onCount).toHaveBeenCalledTimes(2);
231
347
  });
232
348
 
233
- it("should handle widget with no initialize or render", async () => {
234
- const widgetDef = {};
235
- const renderFn = await binding.bind(widgetDef, model);
236
- expect(typeof renderFn).toBe("function");
349
+ it("should auto-clear initialize listeners on destroy", async () => {
350
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
351
+ const onCount = vi.fn();
352
+ const widgetDef = {
353
+ initialize: vi.fn(({ model }) => {
354
+ model.on("change:count", onCount);
355
+ }),
356
+ render: vi.fn(),
357
+ };
358
+ const binding = await createBinding(widgetDef, model);
237
359
 
238
- // Render should not throw
239
- const el = document.createElement("div");
240
- const controller = new AbortController();
241
- await renderFn(el, controller.signal);
360
+ model.set("count", 1);
361
+ expect(onCount).toHaveBeenCalledTimes(1);
362
+
363
+ binding.destroy();
364
+ model.set("count", 2);
365
+ expect(onCount).toHaveBeenCalledTimes(1);
242
366
  });
243
367
  });
244
368
 
245
- describe("BindingManager", () => {
246
- let manager: InstanceType<typeof BindingManager>;
369
+ describe("WidgetBinding hydration replay", () => {
370
+ it("replays current state to render listeners exactly once", async () => {
371
+ 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);
247
384
 
248
- beforeEach(() => {
249
- manager = new BindingManager();
385
+ const controller = new AbortController();
386
+ await binding.createView({ el }, { signal: controller.signal });
387
+ expect(el.textContent).toBe("count is 8");
250
388
  });
251
389
 
252
- it("should create bindings on demand", () => {
253
- const modelId = asModelId("model-1");
254
- expect(manager.has(modelId)).toBe(false);
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>> = [];
395
+ const widgetDef = {
396
+ render: vi.fn(({ model }) => {
397
+ const listener = vi.fn();
398
+ listeners.push(listener);
399
+ model.on("change:count", listener);
400
+ }),
401
+ };
402
+ const binding = await createBinding(widgetDef, model);
403
+ const controller = new AbortController();
255
404
 
256
- const binding = manager.getOrCreate(modelId);
257
- expect(binding).toBeDefined();
258
- expect(manager.has(modelId)).toBe(true);
405
+ await binding.createView(
406
+ { el: document.createElement("div") },
407
+ { signal: controller.signal },
408
+ );
409
+ expect(listeners[0]).toHaveBeenCalledTimes(1);
410
+
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);
259
418
  });
260
419
 
261
- it("should return the same binding for the same model id", () => {
262
- const modelId = asModelId("model-1");
263
- const binding1 = manager.getOrCreate(modelId);
264
- const binding2 = manager.getOrCreate(modelId);
265
- expect(binding1).toBe(binding2);
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();
423
+ const widgetDef = {
424
+ render: vi.fn(({ model }) => {
425
+ model.on("change", onAnyChange);
426
+ }),
427
+ };
428
+ const binding = await createBinding(widgetDef, model);
429
+ const controller = new AbortController();
430
+ await binding.createView(
431
+ { el: document.createElement("div") },
432
+ { signal: controller.signal },
433
+ );
434
+ expect(onAnyChange).toHaveBeenCalledTimes(1);
266
435
  });
267
436
 
268
- it("should destroy and remove bindings", async () => {
269
- const modelId = asModelId("model-1");
270
- const binding = manager.getOrCreate(modelId);
271
-
272
- const model = new Model<ModelState>({ count: 0 }, createMockComm());
273
- const initCleanup = vi.fn();
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();
274
442
  const widgetDef = {
275
- initialize: vi.fn().mockResolvedValue(initCleanup),
443
+ initialize: vi.fn(({ model }) => {
444
+ model.on("change:count", initListener);
445
+ }),
276
446
  render: vi.fn(),
277
447
  };
278
- await binding.bind(widgetDef, model);
279
-
280
- manager.destroy(modelId);
281
- expect(manager.has(modelId)).toBe(false);
282
- expect(initCleanup).toHaveBeenCalledTimes(1);
448
+ const binding = await createBinding(widgetDef, model);
449
+ const controller = new AbortController();
450
+ await binding.createView(
451
+ { el: document.createElement("div") },
452
+ { signal: controller.signal },
453
+ );
454
+ expect(initListener).not.toHaveBeenCalled();
283
455
  });
284
456
 
285
- it("should handle idempotent destroy", () => {
286
- const modelId = asModelId("model-1");
287
- manager.getOrCreate(modelId);
288
-
289
- // Should not throw
290
- manager.destroy(modelId);
291
- manager.destroy(modelId);
292
- expect(manager.has(modelId)).toBe(false);
457
+ it("clears the element before rendering into it", async () => {
458
+ const model = new Model<ModelState>({ count: 0 }, createMockComm());
459
+ const el = document.createElement("div");
460
+ el.innerHTML = "<span>stale content</span>";
461
+ const binding = await createBinding({ render: vi.fn() }, model);
462
+ const controller = new AbortController();
463
+ await binding.createView({ el }, { signal: controller.signal });
464
+ expect(el.innerHTML).toBe("");
293
465
  });
294
466
  });
@@ -0,0 +1,28 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { parseWidgetRef, WIDGET_REF_PREFIX } from "../widget-ref";
4
+
5
+ describe("parseWidgetRef", () => {
6
+ it("parses an anywidget: ref", () => {
7
+ expect(parseWidgetRef(`${WIDGET_REF_PREFIX}abc-123`)).toBe("abc-123");
8
+ });
9
+
10
+ it("rejects the legacy IPY_MODEL_ prefix", () => {
11
+ expect(() => parseWidgetRef("IPY_MODEL_abc-123")).toThrow(/Invalid/);
12
+ });
13
+
14
+ it("rejects a bare model id", () => {
15
+ expect(() => parseWidgetRef("abc-123")).toThrow(/Invalid/);
16
+ });
17
+
18
+ it("rejects an empty model id", () => {
19
+ expect(() => parseWidgetRef(WIDGET_REF_PREFIX)).toThrow(/Invalid/);
20
+ });
21
+
22
+ it("rejects non-string inputs", () => {
23
+ expect(() => parseWidgetRef(undefined)).toThrow(/Invalid/);
24
+ expect(() => parseWidgetRef(null)).toThrow(/Invalid/);
25
+ expect(() => parseWidgetRef(42)).toThrow(/Invalid/);
26
+ expect(() => parseWidgetRef({ model_id: "abc" })).toThrow(/Invalid/);
27
+ });
28
+ });
@@ -0,0 +1,54 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { AnyModel, Host, ResolvedWidget } from "@anywidget/types";
4
+ import { modelProxy } from "./model-proxy";
5
+ import type { Model } from "./model";
6
+ import type { ModelState } from "./types";
7
+ import type { WidgetModelId } from "./types";
8
+ import { parseWidgetRef } from "./widget-ref";
9
+
10
+ export type { Host, ResolvedWidget };
11
+
12
+ export interface WidgetResolver {
13
+ getModel(key: WidgetModelId): Promise<Model<ModelState>>;
14
+ getWidget<T = unknown>(key: WidgetModelId): Promise<ResolvedWidget<T>>;
15
+ }
16
+
17
+ /**
18
+ * Build a `Host` scoped to a parent view's lifetime.
19
+ *
20
+ * The supplied `parentSignal` is used as the default for child renders
21
+ * that don't pass their own `signal` — so a child mounted via
22
+ * `host.getWidget(ref).render({ el })` is automatically torn down when
23
+ * the parent view tears down. Listeners that the child registers on its
24
+ * own model via `model.on(...)` are scoped to the same signal through
25
+ * `modelProxy`.
26
+ */
27
+ export function createHost(
28
+ resolver: WidgetResolver,
29
+ parentSignal: AbortSignal,
30
+ ): Host {
31
+ return {
32
+ async getModel<T extends Record<string, unknown> = Record<string, unknown>>(
33
+ ref: string,
34
+ ): Promise<AnyModel<T>> {
35
+ const modelId = parseWidgetRef(ref);
36
+ const model = await resolver.getModel(modelId);
37
+ // Wrap in a proxy so listeners the parent registers on the child's
38
+ // model are auto-cleared when the parent's view tears down.
39
+ // The generic T is structural — modelProxy returns the underlying
40
+ // AnyModel shape regardless of T, which the caller narrows.
41
+ return modelProxy(model, parentSignal) as unknown as AnyModel<T>;
42
+ },
43
+ async getWidget<T = unknown>(ref: string): Promise<ResolvedWidget<T>> {
44
+ const modelId = parseWidgetRef(ref);
45
+ const widget = await resolver.getWidget<T>(modelId);
46
+ return {
47
+ exports: widget.exports,
48
+ async render({ el, signal }) {
49
+ await widget.render({ el, signal: signal ?? parentSignal });
50
+ },
51
+ };
52
+ },
53
+ };
54
+ }