@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,14 +1,19 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- /* oxlint-disable typescript/no-explicit-any */
3
-
4
- import type { AnyWidget, Experimental } from "@anywidget/types";
2
+ import type {
3
+ AnyWidget,
4
+ Experimental,
5
+ Initialize,
6
+ Render,
7
+ } from "@anywidget/types";
5
8
  import { asRemoteURL } from "@/core/runtime/config";
6
9
  import { resolveVirtualFileURL } from "@/core/static/files";
7
10
  import { isStaticNotebook } from "@/core/static/static-state";
8
11
  import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
9
12
  import { Logger } from "@/utils/Logger";
13
+ import type { Host } from "./host";
10
14
  import type { Model } from "./model";
11
- import type { ModelState, WidgetModelId } from "./types";
15
+ import { modelProxy, type ProxyRegistration } from "./model-proxy";
16
+ import type { ModelState } from "./types";
12
17
 
13
18
  export const experimental: Experimental = {
14
19
  invoke: async () => {
@@ -19,8 +24,6 @@ export const experimental: Experimental = {
19
24
  },
20
25
  };
21
26
 
22
- export type RenderFn = (el: HTMLElement, signal: AbortSignal) => Promise<void>;
23
-
24
27
  /**
25
28
  * Polyfill for AbortSignal.any. Returns a signal that aborts when any of the
26
29
  * input signals abort. This can be removed once the Node.js test environment
@@ -48,19 +51,26 @@ function abortSignalAny(signals: AbortSignal[]): AbortSignal {
48
51
  * A single import is shared across all widget instances using the same module.
49
52
  */
50
53
  class WidgetDefRegistry {
51
- #cache = new Map<string, Promise<any>>();
54
+ #cache = new Map<string, Promise<unknown>>();
52
55
 
53
56
  /**
54
- * Get (or start) the ESM import for a widget module.
55
- * Cached by jsHash so multiple instances share one import.
57
+ * Get (or start) the ESM import for a widget module, cached by
58
+ * jsHash so multiple instances share one import. Pass
59
+ * `kernelAuthored: true` only for URLs from an `EsmSpec`; it widens
60
+ * the import gate to remote and data URLs.
56
61
  */
57
- getModule(jsUrl: string, jsHash: string): Promise<any> {
62
+ getModule(options: {
63
+ jsUrl: string;
64
+ jsHash: string;
65
+ kernelAuthored?: boolean;
66
+ }): Promise<unknown> {
67
+ const { jsUrl, jsHash, kernelAuthored = false } = options;
58
68
  const cached = this.#cache.get(jsHash);
59
69
  if (cached) {
60
70
  return cached;
61
71
  }
62
72
 
63
- const promise = this.#doImport(jsUrl).catch((error) => {
73
+ const promise = this.#doImport(jsUrl, { kernelAuthored }).catch((error) => {
64
74
  // On failure, remove from cache so a retry with a new URL can work
65
75
  this.#cache.delete(jsHash);
66
76
  throw error;
@@ -70,23 +80,19 @@ class WidgetDefRegistry {
70
80
  return promise;
71
81
  }
72
82
 
73
- /**
74
- * Invalidate a cached module (e.g. for hot-reload support).
75
- */
76
- invalidate(jsHash: string): void {
77
- Logger.debug(
78
- `[WidgetDefRegistry] Invalidating module cache for hash=${jsHash}`,
79
- );
80
- this.#cache.delete(jsHash);
81
- }
82
-
83
- async #doImport(jsUrl: string): Promise<any> {
84
- // Only trust marimo virtual file paths. Accepting arbitrary URLs
85
- // would let a raw `<marimo-anywidget data-js-url=...>` element
86
- // embedded in a markdown cell dynamically import attacker-controlled
87
- // JavaScript at same origin (the HTML sanitizer allows any marimo-*
88
- // custom element with any attribute through to the plugin layer).
89
- if (!isTrustedVirtualFileUrl(jsUrl)) {
83
+ async #doImport(
84
+ jsUrl: string,
85
+ opts: { kernelAuthored?: boolean } = {},
86
+ ): Promise<unknown> {
87
+ // By default, only trust marimo virtual file paths: arbitrary URLs
88
+ // would let markdown-injected elements import attacker-controlled
89
+ // JavaScript. Kernel-authored URLs (from an `EsmSpec`) are exempt —
90
+ // a client cannot forge a notification to its peers. data: URLs
91
+ // cover runtimes where virtual files are unsupported.
92
+ const trusted =
93
+ isTrustedVirtualFileUrl(jsUrl) ||
94
+ (opts.kernelAuthored === true && /^(https?:|data:)/.test(jsUrl));
95
+ if (!trusted) {
90
96
  throw new Error(
91
97
  `Refusing to load anywidget module from untrusted URL: ${String(
92
98
  jsUrl,
@@ -102,128 +108,283 @@ class WidgetDefRegistry {
102
108
  }
103
109
 
104
110
  /**
105
- * Connects a Model to a resolved AnyWidget definition.
106
- * Owns the initialize lifecycle and produces a render function.
111
+ * Resolved widget def what `initialize` and `render` are actually called
112
+ * on. `AnyWidget` is either this shape directly or a factory that returns it.
113
+ */
114
+ interface ResolvedWidget<T extends ModelState> {
115
+ initialize?: Initialize<T>;
116
+ render?: Render<T>;
117
+ }
118
+
119
+ type HostFactory = (signal: AbortSignal) => Host;
120
+ type Cleanup = () => void | PromiseLike<void>;
121
+
122
+ function isCleanup(value: unknown): value is Cleanup {
123
+ return typeof value === "function";
124
+ }
125
+
126
+ interface CreateWidgetBindingOptions<T extends ModelState> {
127
+ widgetDef: AnyWidget<T>;
128
+ model: Model<T>;
129
+ createHost: HostFactory;
130
+ controller?: AbortController;
131
+ }
132
+
133
+ async function safelyRunCleanup(cleanup: Cleanup, reason: string) {
134
+ try {
135
+ await cleanup();
136
+ } catch (error) {
137
+ Logger.warn(`[WidgetBinding] ${reason} cleanup failed`, error);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * One immutable generation of a widget: a resolved AnyWidget definition
143
+ * paired with a Model. Constructed by `WidgetBinding.create` and never
144
+ * rebinds; a code change replaces the whole generation.
107
145
  *
108
146
  * Per AFM spec:
109
- * - initialize() is called once per model (or once per hot-reload)
110
- * - render() (the returned function) is called once per view
147
+ * - initialize() ran exactly once, in `create`
148
+ * - render() runs once per view (zero or more per generation)
111
149
  */
112
- class WidgetBinding<T extends ModelState = ModelState> {
113
- #controller: AbortController | undefined;
114
- #widgetDef: AnyWidget<T> | undefined;
115
- #render: RenderFn | undefined;
150
+ export class WidgetBinding<T extends ModelState = ModelState> {
151
+ #controller: AbortController;
152
+ #widget: ResolvedWidget<T>;
153
+ #model: Model<T>;
154
+ #exports: unknown;
155
+ #createHost: HostFactory;
156
+ #cleanupTasks = new Set<Promise<void>>();
157
+ #viewTasks = new Set<Promise<void>>();
158
+
159
+ private constructor(options: {
160
+ widget: ResolvedWidget<T>;
161
+ model: Model<T>;
162
+ exports: unknown;
163
+ controller: AbortController;
164
+ createHost: HostFactory;
165
+ }) {
166
+ this.#widget = options.widget;
167
+ this.#model = options.model;
168
+ this.#exports = options.exports;
169
+ this.#controller = options.controller;
170
+ this.#createHost = options.createHost;
171
+ }
116
172
 
117
173
  /**
118
- * Bind a widget definition to a model.
119
- * If the same def is already bound, returns the cached render function.
120
- * If a different def is provided (hot reload), tears down the old binding
121
- * and re-initializes.
174
+ * Resolve the widget definition (calling it if it is a factory) and
175
+ * run `initialize` exactly once, with its listeners scoped to
176
+ * `controller`'s signal. Aborting the controller mid-initialize
177
+ * still runs any legacy cleanup callback, and `create` rejects.
122
178
  */
123
- async bind(widgetDef: AnyWidget<T>, model: Model<T>): Promise<RenderFn> {
124
- // Already initialized with the same widget - return cached render
125
- if (this.#render && this.#widgetDef === widgetDef) {
126
- return this.#render;
127
- }
179
+ static async create<T extends ModelState>({
180
+ widgetDef,
181
+ model,
182
+ createHost,
183
+ controller = new AbortController(),
184
+ }: CreateWidgetBindingOptions<T>): Promise<WidgetBinding<T>> {
185
+ const signal = controller.signal;
186
+ const widget: ResolvedWidget<T> =
187
+ typeof widgetDef === "function" ? await widgetDef() : widgetDef;
128
188
 
129
- // If widgetDef changed (hot reload), destroy old and re-initialize
130
- if (this.#render && this.#widgetDef !== widgetDef) {
131
- Logger.debug(
132
- "[WidgetBinding] Hot-reload detected, aborting previous binding",
133
- );
134
- this.#controller?.abort();
135
- this.#controller = undefined;
136
- this.#render = undefined;
137
- }
189
+ const initPromise = Promise.resolve(
190
+ widget.initialize?.({
191
+ model: modelProxy(model, signal),
192
+ experimental,
193
+ signal,
194
+ }),
195
+ );
196
+ // If destroyed mid-initialize, still run a late-arriving legacy
197
+ // cleanup callback: the widget acquired resources.
198
+ initPromise
199
+ .then(async (settled) => {
200
+ if (signal.aborted && isCleanup(settled)) {
201
+ await safelyRunCleanup(settled, "late initialize");
202
+ }
203
+ })
204
+ .catch(() => undefined);
138
205
 
139
- this.#widgetDef = widgetDef;
140
- this.#controller = new AbortController();
141
- const bindingSignal = this.#controller.signal;
206
+ // Race against destruction so a hung `initialize` can't strand
207
+ // callers whose model already closed.
208
+ const result = await Promise.race([
209
+ initPromise,
210
+ new Promise<never>((_, reject) => {
211
+ const abort = () => reject(new Error("[anywidget] binding destroyed"));
212
+ if (signal.aborted) {
213
+ abort();
214
+ return;
215
+ }
216
+ signal.addEventListener("abort", abort, { once: true });
217
+ }),
218
+ ]);
142
219
 
143
- // Resolve the widget definition (call if it's a function)
144
- const widget =
145
- typeof widgetDef === "function" ? await widgetDef() : widgetDef;
220
+ if (signal.aborted) {
221
+ throw new Error("[anywidget] binding destroyed");
222
+ }
146
223
 
147
- // Call initialize once per model
148
- const cleanup = await widget.initialize?.({ model, experimental });
149
- if (cleanup) {
150
- bindingSignal.addEventListener("abort", cleanup);
224
+ // Distinguish anywidget's three return shapes:
225
+ // function → legacy cleanup callback (run on destroy)
226
+ // object → widget exports (anywidget>=0.11)
227
+ // void → nothing
228
+ let exports: unknown;
229
+ let initializeCleanup: Cleanup | undefined;
230
+ if (isCleanup(result)) {
231
+ initializeCleanup = result;
232
+ exports = undefined;
233
+ } else if (typeof result === "object" && result !== null) {
234
+ exports = result;
235
+ } else {
236
+ exports = undefined;
151
237
  }
152
238
 
153
- // Store and return the render closure
154
- this.#render = async (el: HTMLElement, viewSignal: AbortSignal) => {
155
- const renderCleanup = await widget.render?.({
156
- model,
157
- el,
158
- experimental,
159
- });
160
- if (renderCleanup) {
161
- // Cleanup when either the view unmounts or the binding is destroyed
162
- const combined = abortSignalAny([viewSignal, bindingSignal]);
163
- combined.addEventListener("abort", () => {
164
- const reason = viewSignal.aborted
165
- ? "view unmount"
166
- : "binding destroyed";
167
- Logger.debug(
168
- `[WidgetBinding] Render cleanup triggered (reason: ${reason})`,
169
- );
170
- renderCleanup();
171
- });
172
- }
173
- };
239
+ const binding = new WidgetBinding({
240
+ widget,
241
+ model,
242
+ exports,
243
+ controller,
244
+ createHost,
245
+ });
246
+ if (initializeCleanup) {
247
+ signal.addEventListener(
248
+ "abort",
249
+ () => void binding.#trackCleanup(initializeCleanup, "initialize"),
250
+ { once: true },
251
+ );
252
+ }
253
+ return binding;
254
+ }
174
255
 
175
- return this.#render;
256
+ /**
257
+ * The object returned from `initialize`, or `undefined` if it
258
+ * returned a cleanup function or nothing.
259
+ */
260
+ get exports(): unknown {
261
+ return this.#exports;
176
262
  }
177
263
 
178
264
  /**
179
- * Destroy this binding, aborting the initialize lifecycle.
265
+ * Mount a view of this widget into `el`. Each view's signal aborts
266
+ * when the caller's `signal` fires or the binding is destroyed, and
267
+ * listeners registered inside `render` auto-clear on abort.
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.
180
272
  */
181
- destroy(): void {
182
- Logger.debug(
183
- "[WidgetBinding] Destroying binding, aborting initialize lifecycle",
184
- );
185
- this.#controller?.abort();
186
- this.#controller = undefined;
187
- this.#widgetDef = undefined;
188
- this.#render = undefined;
273
+ async createView(
274
+ target: { el: HTMLElement },
275
+ options: { signal: AbortSignal },
276
+ ): Promise<void> {
277
+ const task = this.#createView(target, options);
278
+ this.#viewTasks.add(task);
279
+ try {
280
+ await task;
281
+ } finally {
282
+ this.#viewTasks.delete(task);
283
+ }
189
284
  }
190
- }
191
285
 
192
- /**
193
- * Maps WidgetModelId to WidgetBinding instances.
194
- * Singleton that manages the lifecycle of all bindings.
195
- */
196
- class BindingManager {
197
- #bindings = new Map<WidgetModelId, WidgetBinding<any>>();
198
-
199
- getOrCreate(modelId: WidgetModelId): WidgetBinding<any> {
200
- let binding = this.#bindings.get(modelId);
201
- if (!binding) {
202
- binding = new WidgetBinding();
203
- this.#bindings.set(modelId, binding);
286
+ async #createView(
287
+ target: { el: HTMLElement },
288
+ options: { signal: AbortSignal },
289
+ ): Promise<void> {
290
+ const widget = this.#widget;
291
+ if (!widget.render) {
292
+ return;
204
293
  }
205
- return binding;
294
+ // `renderSignal` aborts when either the caller's view unmounts or
295
+ // the binding itself is destroyed.
296
+ const renderSignal = abortSignalAny([
297
+ options.signal,
298
+ this.#controller.signal,
299
+ ]);
300
+ if (renderSignal.aborted) {
301
+ return;
302
+ }
303
+ // Clear whatever a previous generation or render left behind.
304
+ target.el.innerHTML = "";
305
+ // Each view gets a host scoped to its own signal so child views tear
306
+ // down with this view.
307
+ const host = this.#createHost(renderSignal);
308
+ const registrations: ProxyRegistration[] = [];
309
+ const renderCleanup = await widget.render({
310
+ model: modelProxy(this.#model, renderSignal, (registration) =>
311
+ registrations.push(registration),
312
+ ),
313
+ el: target.el,
314
+ experimental,
315
+ signal: renderSignal,
316
+ host,
317
+ });
318
+ if (isCleanup(renderCleanup)) {
319
+ const runCleanup = () => {
320
+ const reason = options.signal.aborted
321
+ ? "view unmount"
322
+ : "binding destroyed";
323
+ Logger.debug(
324
+ `[WidgetBinding] Render cleanup triggered (reason: ${reason})`,
325
+ );
326
+ return this.#trackCleanup(renderCleanup, "render");
327
+ };
328
+ if (renderSignal.aborted) {
329
+ await runCleanup();
330
+ return;
331
+ }
332
+ renderSignal.addEventListener("abort", () => void runCleanup(), {
333
+ once: true,
334
+ });
335
+ }
336
+ this.#replayState(registrations, renderSignal);
337
+ }
338
+
339
+ #trackCleanup(cleanup: Cleanup, reason: string): Promise<void> {
340
+ const task = safelyRunCleanup(cleanup, reason);
341
+ this.#cleanupTasks.add(task);
342
+ void task.finally(() => this.#cleanupTasks.delete(task));
343
+ return task;
206
344
  }
207
345
 
208
- destroy(modelId: WidgetModelId): void {
209
- const binding = this.#bindings.get(modelId);
210
- if (binding) {
211
- Logger.debug(`[BindingManager] Destroying binding for model=${modelId}`);
212
- binding.destroy();
213
- this.#bindings.delete(modelId);
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
+ }
214
368
  }
215
369
  }
216
370
 
217
- has(modelId: WidgetModelId): boolean {
218
- return this.#bindings.has(modelId);
371
+ /**
372
+ * Destroy this generation, running initialize/render cleanups and
373
+ * clearing listeners registered through its model proxies.
374
+ */
375
+ async destroy(): Promise<void> {
376
+ Logger.debug("[WidgetBinding] Destroying binding generation");
377
+ this.#controller.abort();
378
+ await Promise.allSettled(this.#viewTasks);
379
+ while (this.#cleanupTasks.size > 0) {
380
+ await Promise.all(this.#cleanupTasks);
381
+ }
219
382
  }
220
383
  }
221
384
 
222
385
  export const WIDGET_DEF_REGISTRY = new WidgetDefRegistry();
223
- export const BINDING_MANAGER = new BindingManager();
224
386
 
225
387
  export const visibleForTesting = {
226
388
  WidgetDefRegistry,
227
389
  WidgetBinding,
228
- BindingManager,
229
390
  };
@@ -0,0 +1,29 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { isWidgetModelId, type WidgetModelId } from "./types";
4
+
5
+ export const WIDGET_REF_PREFIX = "anywidget:";
6
+
7
+ /**
8
+ * Parse a widget reference string into its model id.
9
+ *
10
+ * Per anywidget>=0.11, widget refs are serialized as
11
+ * `anywidget:<model_id>` strings embedded directly in widget state.
12
+ * `host.getWidget(ref)` consumes these strings.
13
+ *
14
+ * The legacy `IPY_MODEL_<id>` prefix used by ipywidgets'
15
+ * `widget_serialization` is intentionally NOT accepted here — widgets
16
+ * using that path resolve children manually via
17
+ * `model.widget_manager.get_model(id)` after stripping the prefix.
18
+ */
19
+ export function parseWidgetRef(ref: unknown): WidgetModelId {
20
+ if (typeof ref === "string" && ref.startsWith(WIDGET_REF_PREFIX)) {
21
+ const modelId = ref.slice(WIDGET_REF_PREFIX.length);
22
+ if (isWidgetModelId(modelId)) {
23
+ return modelId;
24
+ }
25
+ }
26
+ throw new Error(
27
+ `[anywidget] Invalid widget reference: ${JSON.stringify(ref)}`,
28
+ );
29
+ }