@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.
- package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
- package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
- package/dist/assets/{worker-DEDLIQQV.js → worker-DAWRHcPq.js} +2 -2
- package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DinOvbWu.js} +3248 -3044
- package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
- package/dist/{code-visibility-C90Am97q.js → code-visibility-EvXXzjfW.js} +1169 -960
- package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
- package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
- package/dist/{formats-d6MhLuQ9.js → formats-Dzx4J_z1.js} +1 -1
- package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-CjTu7ukN.js} +2084 -1889
- package/dist/{html-to-image-CGp_08St.js → html-to-image-_wGfk8V-.js} +2389 -2322
- package/dist/{input-CbEz_aj_.js → input-DtsN7xm-.js} +1 -1
- package/dist/main.js +3630 -1801
- package/dist/{mermaid-CJW9vIyO.js → mermaid-BYqXy_NE.js} +2 -2
- package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
- package/dist/{process-output-R6JsYrv3.js → process-output-Mh4UrjwM.js} +1 -1
- package/dist/{reveal-component-C_u9FY4_.js → reveal-component-CKfV5wlk.js} +600 -596
- package/dist/{spec-Bv-XlYiv.js → spec-Cz-Bj1JI.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-D-l5s8nn.js → toDate-CWNNlFEX.js} +15 -7
- package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-KfHB8wQR.js} +1 -1
- package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
- package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-DegSo0lV.js} +1 -1
- package/dist/{useTheme-BrYvK-_A.js → useTheme-6eZ3GOTS.js} +31 -26
- package/dist/{vega-component-Pk6lyc_a.js → vega-component-DzyyM9fc.js} +5 -5
- package/package.json +4 -4
- package/src/__mocks__/requests.ts +1 -0
- package/src/__tests__/CellStatus.test.tsx +1 -2
- package/src/components/app-config/user-config-form.tsx +52 -0
- package/src/components/chat/acp/agent-panel.tsx +35 -1
- package/src/components/chat/chat-panel.tsx +68 -29
- package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
- package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
- package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
- package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
- package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
- package/src/components/data-table/charts/charts.tsx +23 -1
- package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
- package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
- package/src/components/debugger/debugger-code.tsx +7 -2
- package/src/components/editor/actions/useNotebookActions.tsx +2 -2
- package/src/components/editor/cell/CellStatus.tsx +1 -20
- package/src/components/editor/cell/PendingDeleteConfirmation.tsx +1 -1
- package/src/components/editor/cell/cell-context-menu.tsx +2 -2
- package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
- package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
- package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
- package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
- package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
- package/src/components/editor/code/readonly-python-code.tsx +60 -33
- package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
- package/src/components/editor/errors/auto-fix.tsx +88 -34
- package/src/components/editor/errors/fix-mode.ts +1 -1
- package/src/components/editor/notebook-cell.tsx +7 -0
- package/src/components/editor/output/ImageOutput.tsx +20 -6
- package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
- package/src/components/editor/output/__tests__/ImageOutput.test.tsx +53 -0
- package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
- package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
- package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
- package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
- package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
- package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
- package/src/components/slides/__tests__/slide.test.tsx +33 -0
- package/src/components/slides/minimap.tsx +7 -1
- package/src/components/slides/reveal-component.tsx +3 -0
- package/src/components/slides/slide-cell-view.tsx +10 -6
- package/src/components/slides/slide.tsx +16 -13
- package/src/components/tracing/tracing.tsx +2 -1
- package/src/core/ai/config.ts +2 -2
- package/src/core/ai/context/__tests__/registry.test.ts +133 -3
- package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
- package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
- package/src/core/ai/context/providers/datasource.ts +27 -2
- package/src/core/ai/context/providers/error.ts +226 -36
- package/src/core/ai/context/registry.ts +77 -43
- package/src/core/ai/state.ts +11 -0
- package/src/core/cells/__tests__/cell.test.ts +39 -0
- package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
- package/src/core/cells/cell.ts +5 -3
- package/src/core/cells/readonly-code-display.ts +35 -0
- package/src/core/codemirror/__tests__/setup.test.ts +33 -1
- package/src/core/codemirror/ai/resources.ts +15 -10
- package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
- package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +224 -0
- package/src/core/codemirror/cells/__tests__/line-timing-decorations.test.ts +126 -0
- package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
- package/src/core/codemirror/cells/debugger-state.ts +133 -0
- package/src/core/codemirror/cells/extensions.ts +32 -4
- package/src/core/codemirror/cells/line-timing-decorations.ts +165 -0
- package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
- package/src/core/codemirror/go-to-definition/extension.ts +3 -3
- package/src/core/codemirror/go-to-definition/underline.ts +5 -14
- package/src/core/codemirror/go-to-definition/utils.ts +49 -1
- package/src/core/codemirror/utils.ts +15 -0
- package/src/core/config/__tests__/config-schema.test.ts +17 -0
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/config/config.ts +10 -0
- package/src/core/config/feature-flag.tsx +4 -0
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/errors.ts +2 -1
- package/src/core/islands/bootstrap.ts +4 -3
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
- package/src/core/lifecycle/render-policy.ts +125 -0
- package/src/core/mime.ts +11 -4
- package/src/core/network/__tests__/api.test.ts +17 -0
- package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
- package/src/core/network/api.ts +17 -6
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +8 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +10 -1
- package/src/core/network/types.ts +2 -0
- package/src/core/run-app.tsx +27 -23
- package/src/core/runtime/__tests__/adapter.test.ts +160 -0
- package/src/core/runtime/__tests__/runtime.test.ts +45 -0
- package/src/core/runtime/adapter.ts +182 -0
- package/src/core/runtime/runtime.ts +30 -16
- package/src/core/wasm/PyodideLoader.tsx +20 -62
- package/src/core/wasm/bridge.ts +13 -4
- package/src/core/wasm/state.ts +8 -19
- package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +4 -0
- package/src/core/websocket/__tests__/useWebSocket.test.ts +49 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +338 -0
- package/src/core/websocket/transports/sse.ts +308 -0
- package/src/core/websocket/useMarimoKernelConnection.tsx +58 -7
- package/src/core/websocket/useWebSocket.tsx +12 -2
- package/src/css/app/codemirror.css +14 -0
- package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
- package/src/hooks/useDelayElapsed.ts +27 -0
- package/src/mount.tsx +3 -3
- package/src/plugins/core/RenderHTML.tsx +35 -1
- package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
- package/src/plugins/impl/DataTablePlugin.tsx +1 -0
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +33 -254
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +116 -174
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +313 -0
- package/src/plugins/impl/anywidget/__tests__/model-proxy.test.ts +158 -0
- package/src/plugins/impl/anywidget/__tests__/model.test.ts +8 -177
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +708 -0
- package/src/plugins/impl/anywidget/__tests__/resolve-widget.test.ts +132 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +305 -133
- package/src/plugins/impl/anywidget/__tests__/widget-ref.test.ts +28 -0
- package/src/plugins/impl/anywidget/host.ts +54 -0
- package/src/plugins/impl/anywidget/model-proxy.ts +70 -0
- package/src/plugins/impl/anywidget/model.ts +59 -239
- package/src/plugins/impl/anywidget/registry.ts +268 -0
- package/src/plugins/impl/anywidget/resolve-widget.ts +138 -0
- package/src/plugins/impl/anywidget/runtime.ts +422 -0
- package/src/plugins/impl/anywidget/types.ts +14 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +280 -119
- package/src/plugins/impl/anywidget/widget-ref.ts +29 -0
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
- package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +3 -2
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +16 -13
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +1 -1
- package/src/utils/errors.ts +15 -0
- package/src/utils/time.ts +20 -0
- package/dist/assets/__vite-browser-external-DAm_YW43.js +0 -1
- package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
- package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
- package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
- package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
- package/src/core/wasm/__tests__/state.test.ts +0 -124
- 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 {
|
|
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
|
|
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 =
|
|
57
|
-
const promise2 =
|
|
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 =
|
|
65
|
-
const promise2 =
|
|
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 =
|
|
73
|
-
const promise2 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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(
|
|
139
|
-
render: vi.fn()
|
|
148
|
+
initialize: vi.fn().mockResolvedValue(exports),
|
|
149
|
+
render: vi.fn(),
|
|
140
150
|
};
|
|
141
151
|
|
|
142
|
-
const
|
|
152
|
+
const binding = await createBinding(widgetDef, model);
|
|
143
153
|
expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
|
|
144
|
-
expect(
|
|
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
|
|
150
|
-
|
|
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
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
179
|
-
const
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
expect(
|
|
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
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
viewController.abort();
|
|
298
|
+
await pending;
|
|
200
299
|
expect(renderCleanup).toHaveBeenCalledTimes(1);
|
|
201
300
|
});
|
|
202
301
|
|
|
203
|
-
it("should
|
|
204
|
-
const
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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(
|
|
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
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
333
|
+
const binding = await createBinding(widgetDef, model);
|
|
227
334
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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("
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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("
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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("
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
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("
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
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(
|
|
443
|
+
initialize: vi.fn(({ model }) => {
|
|
444
|
+
model.on("change:count", initListener);
|
|
445
|
+
}),
|
|
276
446
|
render: vi.fn(),
|
|
277
447
|
};
|
|
278
|
-
await
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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("
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
expect(
|
|
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
|
+
}
|