@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
@@ -0,0 +1,70 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { AnyModel } from "@anywidget/types";
4
+ import type { ModelState } from "./types";
5
+
6
+ type ModelEventCallback = Parameters<AnyModel<ModelState>["on"]>[1];
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
+ /**
18
+ * Wrap a model so every `on()` call from inside `initialize` or `render`
19
+ * is auto-tied to a lifetime `AbortSignal`. When the signal aborts,
20
+ * every listener registered through the proxy is removed.
21
+ *
22
+ * Without this, a parent widget that re-renders a child via
23
+ * `host.getWidget().render` would accumulate listeners on the child's
24
+ * model on every re-render — the widget author would have to know to
25
+ * pass `signal` to each `on()` call themselves.
26
+ *
27
+ * The proxy is purely a host-side ergonomic helper. The widget author
28
+ * still writes `model.on("change:foo", handler)` exactly as before; the
29
+ * cleanup signal is supplied transparently.
30
+ *
31
+ * `onRegister`, when given, observes each `on()` registration.
32
+ */
33
+ // oxlint-disable-next-line marimo/prefer-object-params -- concise internal helper used at protocol call sites
34
+ export function modelProxy<T extends ModelState>(
35
+ model: AnyModel<T>,
36
+ signal: AbortSignal,
37
+ onRegister?: (registration: ProxyRegistration) => void,
38
+ ): AnyModel<T> {
39
+ return {
40
+ get(key) {
41
+ return model.get(key);
42
+ },
43
+ set(key, value) {
44
+ model.set(key, value);
45
+ },
46
+ save_changes() {
47
+ model.save_changes();
48
+ },
49
+ send: model.send.bind(model),
50
+ on(name: string, callback: ModelEventCallback): void {
51
+ if (signal.aborted) {
52
+ return;
53
+ }
54
+ model.on(name, callback);
55
+ signal.addEventListener("abort", () => model.off(name, callback), {
56
+ once: true,
57
+ });
58
+ onRegister?.({ event: name, callback });
59
+ },
60
+ off(name?: string | null, callback?: ModelEventCallback | null): void {
61
+ model.off(name ?? null, callback ?? null);
62
+ },
63
+ widget_manager: {
64
+ async get_model<TT extends ModelState>(modelId: string) {
65
+ const child = await model.widget_manager.get_model<TT>(modelId);
66
+ return modelProxy(child, signal);
67
+ },
68
+ },
69
+ };
70
+ }
@@ -3,104 +3,24 @@
3
3
 
4
4
  import type { AnyModel } from "@anywidget/types";
5
5
  import { debounce } from "lodash-es";
6
- import type { NotificationMessageData } from "@/core/kernel/messages";
7
- import { getRequestClient } from "@/core/network/requests";
8
- import { isStaticNotebook } from "@/core/static/static-state";
9
- import {
10
- decodeFromWire,
11
- serializeBuffersToBase64,
12
- } from "@/plugins/impl/anywidget/serialization";
13
- import { assertNever } from "@/utils/assertNever";
14
- import { Deferred } from "@/utils/Deferred";
15
- import {
16
- type Base64String,
17
- base64ToDataView,
18
- dataViewToBase64,
19
- } from "@/utils/json/base64";
20
6
  import { Logger } from "@/utils/Logger";
21
- import { repl } from "@/utils/repl";
22
- import type { AnyWidgetMessage } from "./schemas";
23
7
  import type { EventHandler, ModelState, WidgetModelId } from "./types";
24
- import { BINDING_MANAGER } from "./widget-binding";
25
8
 
26
- interface ModelEntry {
27
- deferred: Deferred<Model<ModelState>>;
28
- controller: AbortController;
29
- }
30
-
31
- class ModelManager {
32
- #entries = new Map<WidgetModelId, ModelEntry>();
33
- #timeout: number;
34
-
35
- constructor(timeout = 10_000) {
36
- this.#timeout = timeout;
37
- }
38
-
39
- #getOrCreateEntry(key: WidgetModelId): ModelEntry {
40
- let entry = this.#entries.get(key);
41
- if (!entry) {
42
- entry = {
43
- deferred: new Deferred<Model<ModelState>>(),
44
- controller: new AbortController(),
45
- };
46
- this.#entries.set(key, entry);
47
- }
48
- return entry;
49
- }
50
-
51
- get(key: WidgetModelId): Promise<Model<any>> {
52
- const entry = this.#getOrCreateEntry(key);
53
- if (entry.deferred.status === "pending") {
54
- // Add timeout to prevent hanging
55
- setTimeout(() => {
56
- if (entry.deferred.status !== "pending") {
57
- return;
58
- }
59
- entry.deferred.reject(new Error(`Model not found for key: ${key}`));
60
- this.#entries.delete(key);
61
- }, this.#timeout);
62
- }
63
- return entry.deferred.promise;
64
- }
65
-
66
- /**
67
- * Create a model with a managed lifecycle signal.
68
- * The signal is aborted when the model is deleted.
69
- */
70
- create(
71
- key: WidgetModelId,
72
- factory: (signal: AbortSignal) => Model<ModelState>,
73
- ): void {
74
- const entry = this.#getOrCreateEntry(key);
75
- entry.deferred.resolve(factory(entry.controller.signal));
76
- }
77
-
78
- set(key: WidgetModelId, model: Model<any>): void {
79
- this.#getOrCreateEntry(key).deferred.resolve(model);
80
- }
81
-
82
- /**
83
- * Get a model synchronously if it exists and has been resolved.
84
- * Returns undefined if the model doesn't exist or is still pending.
85
- */
86
- getSync(key: WidgetModelId): Model<any> | undefined {
87
- const entry = this.#entries.get(key);
88
- if (entry && entry.deferred.status === "resolved") {
89
- return entry.deferred.value;
90
- }
91
- return undefined;
92
- }
93
-
94
- delete(key: WidgetModelId): void {
95
- Logger.debug(
96
- `[ModelManager] Deleting model=${key}, aborting lifecycle signal`,
97
- );
98
- this.#entries.get(key)?.controller.abort();
99
- this.#entries.delete(key);
100
- }
9
+ /**
10
+ * The custom-message shape delivered to `msg:custom` listeners — the
11
+ * only message shape the frontend re-declares; the wire contract is
12
+ * the generated `model-lifecycle` type.
13
+ */
14
+ export interface CustomMessage {
15
+ method: "custom";
16
+ // oxlint-disable-next-line typescript/no-explicit-any
17
+ content: any;
101
18
  }
102
19
 
103
- interface MarimoComm<T> {
20
+ /**
21
+ * The model's channel back to the kernel; a no-op in static exports.
22
+ */
23
+ export interface MarimoComm<T> {
104
24
  sendUpdate: (value: Partial<T>) => Promise<void>;
105
25
  sendCustomMessage: (content: unknown, buffers: DataView[]) => Promise<void>;
106
26
  }
@@ -112,15 +32,11 @@ interface MarimoInternalApi<T extends ModelState> {
112
32
  * Update model state and emit change events for any differences.
113
33
  */
114
34
  updateAndEmitDiffs: (value: T) => void;
115
- /**
116
- * Re-emit current state as change events.
117
- */
118
- reemitState: () => void;
119
35
  /**
120
36
  * Emit a custom message to listeners.
121
37
  */
122
38
  emitCustomMessage: (
123
- message: Extract<AnyWidgetMessage, { method: "custom" }>,
39
+ message: CustomMessage,
124
40
  buffers?: readonly DataView[],
125
41
  ) => void;
126
42
  }
@@ -135,8 +51,6 @@ export function getMarimoInternal<T extends ModelState>(
135
51
  return model[marimoSymbol];
136
52
  }
137
53
 
138
- export const MODEL_MANAGER = new ModelManager();
139
-
140
54
  export class Model<T extends ModelState> implements AnyModel<T> {
141
55
  #ANY_CHANGE_EVENT = "change";
142
56
  #dirtyFields: Map<keyof T, unknown>;
@@ -144,7 +58,14 @@ export class Model<T extends ModelState> implements AnyModel<T> {
144
58
  #comm: MarimoComm<T>;
145
59
  #listeners: Record<string, Set<EventHandler> | undefined> = {};
146
60
 
147
- static _modelManager: ModelManager = MODEL_MANAGER;
61
+ /**
62
+ * Resolves models for the legacy ipywidgets escape hatch
63
+ * (`widget_manager.get_model`). Assigned by the registry module at
64
+ * import time to avoid an import cycle.
65
+ */
66
+ static _modelManager: {
67
+ getModel(model_id: WidgetModelId): Promise<Model<any>>;
68
+ };
148
69
 
149
70
  constructor(data: T, comm: MarimoComm<T>, signal?: AbortSignal) {
150
71
  this.#data = data;
@@ -164,9 +85,8 @@ export class Model<T extends ModelState> implements AnyModel<T> {
164
85
  */
165
86
  [marimoSymbol]: MarimoInternalApi<T> = {
166
87
  updateAndEmitDiffs: (value: T) => this.#updateAndEmitDiffs(value),
167
- reemitState: () => this.#reemitState(),
168
88
  emitCustomMessage: (
169
- message: Extract<AnyWidgetMessage, { method: "custom" }>,
89
+ message: CustomMessage,
170
90
  buffers?: readonly DataView[],
171
91
  ) => this.#emitCustomMessage(message, buffers),
172
92
  };
@@ -204,7 +124,7 @@ export class Model<T extends ModelState> implements AnyModel<T> {
204
124
  async get_model<TT extends ModelState>(
205
125
  model_id: WidgetModelId,
206
126
  ): Promise<AnyModel<TT>> {
207
- const model = await Model._modelManager.get(model_id);
127
+ const model = await Model._modelManager.getModel(model_id);
208
128
  if (!model) {
209
129
  throw new Error(
210
130
  `Model not found with id: ${model_id}. This is likely because the model was not registered.`,
@@ -239,19 +159,43 @@ export class Model<T extends ModelState> implements AnyModel<T> {
239
159
  this.#comm.sendUpdate(partialData);
240
160
  }
241
161
 
242
- on(eventName: string, callback: EventHandler): void {
162
+ /**
163
+ * Register an event listener.
164
+ *
165
+ * Passing `signal` ties the listener's lifetime to an `AbortSignal` —
166
+ * when the signal aborts, the listener is auto-removed. This is the
167
+ * preferred cleanup mechanism (matches the web platform pattern of
168
+ * `addEventListener({ signal })`) and is what `modelProxy` uses to
169
+ * scope listeners to view / binding lifetimes.
170
+ */
171
+ on(
172
+ eventName: string,
173
+ callback: EventHandler,
174
+ options?: { signal?: AbortSignal },
175
+ ): void {
176
+ const signal = options?.signal;
177
+ if (signal?.aborted) {
178
+ return;
179
+ }
243
180
  if (!this.#listeners[eventName]) {
244
181
  this.#listeners[eventName] = new Set();
245
182
  }
246
183
  this.#listeners[eventName].add(callback);
184
+ signal?.addEventListener("abort", () => this.off(eventName, callback), {
185
+ once: true,
186
+ });
247
187
  }
248
188
 
249
189
  #emit<K extends keyof T>(event: `change:${K & string}`, value: T[K]) {
250
- if (!this.#listeners[event]) {
190
+ const listeners = this.#listeners[event];
191
+ if (!listeners) {
251
192
  return;
252
193
  }
253
- const listeners = this.#listeners[event];
254
- for (const listener of listeners) {
194
+ // Snapshot before iterating: a callback may unregister itself
195
+ // (typical signal-based abort handler calling `model.off`), and a
196
+ // Set mutated mid-iteration drops the very next element.
197
+ // oxlint-disable-next-line no-useless-spread -- snapshot is intentional
198
+ for (const listener of [...listeners]) {
255
199
  try {
256
200
  listener(value);
257
201
  } catch (error) {
@@ -274,29 +218,21 @@ export class Model<T extends ModelState> implements AnyModel<T> {
274
218
  });
275
219
  }
276
220
 
277
- #reemitState() {
278
- for (const [key, value] of Object.entries(this.#data) as [
279
- keyof T & string,
280
- T[keyof T],
281
- ][]) {
282
- this.#emit(`change:${key}`, value);
283
- }
284
- this.#emitAnyChange();
285
- }
286
-
287
221
  /**
288
222
  * When receiving a message from the backend.
289
223
  * We want to notify all listeners with `msg:custom`
290
224
  */
291
225
  #emitCustomMessage(
292
- message: Extract<AnyWidgetMessage, { method: "custom" }>,
226
+ message: CustomMessage,
293
227
  buffers: readonly DataView[] = [],
294
228
  ) {
295
229
  const listeners = this.#listeners["msg:custom"];
296
230
  if (!listeners) {
297
231
  return;
298
232
  }
299
- for (const listener of listeners) {
233
+ // Snapshot before iterating: see `#emit` for rationale.
234
+ // oxlint-disable-next-line no-useless-spread -- snapshot is intentional
235
+ for (const listener of [...listeners]) {
300
236
  try {
301
237
  listener(message.content, buffers);
302
238
  } catch (error) {
@@ -311,7 +247,9 @@ export class Model<T extends ModelState> implements AnyModel<T> {
311
247
  if (!listeners) {
312
248
  return;
313
249
  }
314
- for (const listener of listeners) {
250
+ // Snapshot before iterating: see `#emit` for rationale.
251
+ // oxlint-disable-next-line no-useless-spread -- snapshot is intentional
252
+ for (const listener of [...listeners]) {
315
253
  try {
316
254
  listener();
317
255
  } catch (error) {
@@ -320,121 +258,3 @@ export class Model<T extends ModelState> implements AnyModel<T> {
320
258
  }
321
259
  }, 0);
322
260
  }
323
-
324
- /**
325
- * Handle an incoming model lifecycle notification from the backend.
326
- *
327
- * Messages are dispatched by method type:
328
- * - "open": Initialize a new model or update existing one with initial state
329
- * - "update": Update model state with new values
330
- * - "custom": Forward custom message to model listeners
331
- * - "close": Remove model from manager
332
- */
333
- export async function handleWidgetMessage(
334
- modelManager: ModelManager,
335
- notification: NotificationMessageData<"model-lifecycle">,
336
- ): Promise<void> {
337
- const modelId = notification.model_id as WidgetModelId;
338
- const msg = notification.message;
339
-
340
- // Decode base64 buffers to DataViews (present in open/update/custom messages)
341
- const base64Buffers: Base64String[] = "buffers" in msg ? msg.buffers : [];
342
- const buffers = base64Buffers.map(base64ToDataView);
343
-
344
- switch (msg.method) {
345
- case "open": {
346
- const { state, buffer_paths = [] } = msg;
347
- const stateWithBuffers = decodeFromWire({
348
- state,
349
- bufferPaths: buffer_paths,
350
- buffers,
351
- });
352
-
353
- // Check if a model already exists (created by the plugin using model_id reference)
354
- // If so, just update its state instead of creating a duplicate
355
- const existingModel = modelManager.getSync(modelId);
356
- if (existingModel) {
357
- getMarimoInternal(existingModel).updateAndEmitDiffs(stateWithBuffers);
358
- return;
359
- }
360
-
361
- modelManager.create(modelId, (signal) => {
362
- // In static exports there is no kernel, so comm calls are no-ops.
363
- const comm: MarimoComm<ModelState> = isStaticNotebook()
364
- ? {
365
- sendUpdate: async () => undefined,
366
- sendCustomMessage: async () => undefined,
367
- }
368
- : {
369
- async sendUpdate(changeData) {
370
- if (signal.aborted) {
371
- Logger.debug(
372
- `[Model] sendUpdate suppressed for model=${modelId} (signal aborted)`,
373
- );
374
- return;
375
- }
376
- const { state, buffers, bufferPaths } =
377
- serializeBuffersToBase64(changeData);
378
- await getRequestClient().sendModelValue({
379
- modelId,
380
- message: { method: "update", state, bufferPaths },
381
- buffers,
382
- });
383
- },
384
- async sendCustomMessage(content, buffers) {
385
- if (signal.aborted) {
386
- Logger.debug(
387
- `[Model] sendCustomMessage suppressed for model=${modelId} (signal aborted)`,
388
- );
389
- return;
390
- }
391
- await getRequestClient().sendModelValue({
392
- modelId,
393
- message: { method: "custom", content },
394
- buffers: buffers.map(dataViewToBase64),
395
- });
396
- },
397
- };
398
-
399
- return new Model(stateWithBuffers, comm, signal);
400
- });
401
- return;
402
- }
403
-
404
- case "custom": {
405
- const model = await modelManager.get(modelId);
406
- // For custom messages, we need to reconstruct the AnyWidgetMessage format
407
- getMarimoInternal(model).emitCustomMessage(
408
- { method: "custom", content: msg.content },
409
- buffers,
410
- );
411
- return;
412
- }
413
-
414
- case "close":
415
- BINDING_MANAGER.destroy(modelId);
416
- modelManager.delete(modelId); // aborts the model's signal, clearing listeners
417
- return;
418
-
419
- case "update": {
420
- const { state, buffer_paths = [] } = msg;
421
- const stateWithBuffers = decodeFromWire({
422
- state,
423
- bufferPaths: buffer_paths,
424
- buffers,
425
- });
426
- const model = await modelManager.get(modelId);
427
- getMarimoInternal(model).updateAndEmitDiffs(stateWithBuffers);
428
- return;
429
- }
430
-
431
- default:
432
- assertNever(msg);
433
- }
434
- }
435
-
436
- repl(MODEL_MANAGER, "MODEL_MANAGER");
437
-
438
- export const visibleForTesting = {
439
- ModelManager,
440
- };