@marimo-team/islands 0.25.3-dev47 → 0.25.3-dev48
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/{ErrorBoundary-B4QYAM0I.js → ErrorBoundary-DHfwqB0K.js} +1 -1
- package/dist/{code-visibility-BbrgXRMG.js → code-visibility-5llx6KCK.js} +1 -1
- package/dist/{common-DPSFvO_K.js → common-DBC1NAar.js} +89 -61
- package/dist/{data-grid-overlay-editor-D0m9lgVY.js → data-grid-overlay-editor-D01HzlXg.js} +1 -1
- package/dist/{feedback-button-BdqMy9oz.js → feedback-button-gTC25Y-p.js} +2 -2
- package/dist/{glide-data-editor-blkbnr2a.js → glide-data-editor-BXXphavz.js} +3 -3
- package/dist/{globals-CvaBSmzH.js → globals-BcxqBRmX.js} +1 -1
- package/dist/main.js +1491 -1403
- package/dist/{number-overlay-editor-yOSMD6Af.js → number-overlay-editor-CaspWg5p.js} +1 -1
- package/dist/{reveal-component-BHOMrvDj.js → reveal-component-CWk_paD2.js} +2 -2
- package/package.json +1 -1
- package/src/components/chat/__tests__/message-queue.test.tsx +17 -0
- package/src/components/chat/chat-panel.tsx +3 -7
- package/src/components/chat/chat-utils.ts +4 -8
- package/src/components/data-table/__tests__/context-menu-interaction.test.tsx +130 -0
- package/src/components/data-table/context-menu.tsx +36 -20
- package/src/components/editor/cell/CreateCellButton.tsx +8 -10
- package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +54 -0
- package/src/components/editor/cell/code/cell-editor.tsx +1 -1
- package/src/components/editor/cell/code/language-toggle.tsx +8 -7
- package/src/components/editor/file-tree/file-viewer.tsx +4 -2
- package/src/components/editor/output/console/ConsoleOutput.tsx +8 -18
- package/src/components/slides/slide-cell-view.tsx +1 -1
- package/src/core/static/files.ts +2 -0
- package/src/hooks/useInputHistory.ts +4 -7
- package/src/hooks/useResizeObserver.ts +4 -7
- package/src/plugins/impl/anywidget/__tests__/registry.test.ts +2 -0
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +241 -2
- package/src/plugins/impl/anywidget/runtime.ts +21 -9
- package/src/plugins/impl/anywidget/widget-binding.ts +160 -82
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +5 -3
- package/src/plugins/impl/panel/PanelPlugin.tsx +15 -16
- package/src/plugins/impl/panel/__tests__/utils.test.ts +39 -1
- package/src/plugins/impl/panel/utils.ts +17 -0
- package/src/stories/__tests__/editor-story.test.tsx +33 -0
- package/src/stories/editor.stories.tsx +24 -17
|
@@ -46,38 +46,80 @@ function abortSignalAny(signals: AbortSignal[]): AbortSignal {
|
|
|
46
46
|
return controller.signal;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
interface ModuleEntry {
|
|
50
|
+
promise: Promise<unknown>;
|
|
51
|
+
references: number;
|
|
52
|
+
dispose: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
/**
|
|
50
56
|
* Deduplicates ESM imports by jsHash.
|
|
51
57
|
* A single import is shared across all widget instances using the same module.
|
|
52
58
|
*/
|
|
53
59
|
class WidgetDefRegistry {
|
|
54
|
-
#cache = new Map<string,
|
|
60
|
+
#cache = new Map<string, ModuleEntry>();
|
|
55
61
|
|
|
56
62
|
/**
|
|
57
63
|
* Get (or start) the ESM import for a widget module, cached by
|
|
58
64
|
* jsHash so multiple instances share one import. Pass
|
|
59
65
|
* `kernelAuthored: true` only for URLs from an `EsmSpec`; it widens
|
|
60
|
-
* the import gate to remote and data URLs.
|
|
66
|
+
* the import gate to remote and data URLs. A lifetime retains the module's
|
|
67
|
+
* temporary URL until that signal aborts; omit it to retain the URL for
|
|
68
|
+
* the notebook's lifetime. The evaluated module itself stays cached.
|
|
61
69
|
*/
|
|
62
70
|
public getModule(options: {
|
|
63
71
|
jsUrl: string;
|
|
64
72
|
jsHash: string;
|
|
65
73
|
kernelAuthored?: boolean;
|
|
74
|
+
lifetime?: AbortSignal;
|
|
66
75
|
}): Promise<unknown> {
|
|
67
|
-
const { jsUrl, jsHash, kernelAuthored = false } = options;
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
76
|
+
const { jsUrl, jsHash, kernelAuthored = false, lifetime } = options;
|
|
77
|
+
let entry = this.#cache.get(jsHash);
|
|
78
|
+
if (!entry) {
|
|
79
|
+
const imported = this.#doImport(jsUrl, { kernelAuthored });
|
|
80
|
+
const created: ModuleEntry = {
|
|
81
|
+
references: 0,
|
|
82
|
+
dispose: () => undefined,
|
|
83
|
+
promise: imported.then(
|
|
84
|
+
({ module, dispose }) => {
|
|
85
|
+
created.dispose = dispose;
|
|
86
|
+
return module;
|
|
87
|
+
},
|
|
88
|
+
(error) => {
|
|
89
|
+
// An invalidated import must not evict its replacement.
|
|
90
|
+
if (this.#cache.get(jsHash) === created) {
|
|
91
|
+
this.#cache.delete(jsHash);
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
this.#cache.set(jsHash, created);
|
|
98
|
+
entry = created;
|
|
71
99
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
const retained = entry;
|
|
101
|
+
retained.references += 1;
|
|
102
|
+
const release = () => {
|
|
103
|
+
retained.references -= 1;
|
|
104
|
+
if (retained.references === 0) {
|
|
105
|
+
// Keep the evaluated module cached: re-importing would re-run its
|
|
106
|
+
// top-level side effects (e.g. `customElements.define`). Only the
|
|
107
|
+
// temporary URL is released.
|
|
108
|
+
void retained.promise.then(
|
|
109
|
+
() => {
|
|
110
|
+
retained.dispose();
|
|
111
|
+
retained.dispose = () => undefined;
|
|
112
|
+
},
|
|
113
|
+
() => undefined,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
if (lifetime?.aborted) {
|
|
118
|
+
release();
|
|
119
|
+
} else {
|
|
120
|
+
lifetime?.addEventListener("abort", release, { once: true });
|
|
121
|
+
}
|
|
122
|
+
return retained.promise;
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
public invalidate(jsHash: string): void {
|
|
@@ -87,7 +129,7 @@ class WidgetDefRegistry {
|
|
|
87
129
|
async #doImport(
|
|
88
130
|
jsUrl: string,
|
|
89
131
|
opts: { kernelAuthored?: boolean } = {},
|
|
90
|
-
): Promise<unknown> {
|
|
132
|
+
): Promise<{ module: unknown; dispose: () => void }> {
|
|
91
133
|
// By default, only trust marimo virtual file paths: arbitrary URLs
|
|
92
134
|
// would let markdown-injected elements import attacker-controlled
|
|
93
135
|
// JavaScript. Kernel-authored URLs (from an `EsmSpec`) are exempt —
|
|
@@ -103,11 +145,23 @@ class WidgetDefRegistry {
|
|
|
103
145
|
)}`,
|
|
104
146
|
);
|
|
105
147
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
148
|
+
const remoteUrl = asRemoteURL(jsUrl).toString();
|
|
149
|
+
const url = isStaticNotebook()
|
|
150
|
+
? resolveVirtualFileURL(remoteUrl)
|
|
151
|
+
: remoteUrl;
|
|
152
|
+
const dispose = () => {
|
|
153
|
+
// Only the static-file resolver's newly created URL belongs to us.
|
|
154
|
+
if (url !== remoteUrl) {
|
|
155
|
+
URL.revokeObjectURL(url);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
const module = await import(/* @vite-ignore */ url);
|
|
160
|
+
return { module, dispose };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
dispose();
|
|
163
|
+
throw error;
|
|
109
164
|
}
|
|
110
|
-
return import(/* @vite-ignore */ url);
|
|
111
165
|
}
|
|
112
166
|
}
|
|
113
167
|
|
|
@@ -132,6 +186,7 @@ interface CreateWidgetBindingOptions<T extends ModelState> {
|
|
|
132
186
|
model: Model<T>;
|
|
133
187
|
createHost: HostFactory;
|
|
134
188
|
controller?: AbortController;
|
|
189
|
+
onDestroy?: () => void;
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
async function safelyRunCleanup(cleanup: Cleanup, reason: string) {
|
|
@@ -159,6 +214,7 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
159
214
|
#createHost: HostFactory;
|
|
160
215
|
#cleanupTasks = new Set<Promise<void>>();
|
|
161
216
|
#viewTasks = new Set<Promise<void>>();
|
|
217
|
+
#onDestroy?: () => void;
|
|
162
218
|
|
|
163
219
|
private constructor(options: {
|
|
164
220
|
widget: ResolvedWidget<T>;
|
|
@@ -166,12 +222,14 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
166
222
|
exports: unknown;
|
|
167
223
|
controller: AbortController;
|
|
168
224
|
createHost: HostFactory;
|
|
225
|
+
onDestroy?: () => void;
|
|
169
226
|
}) {
|
|
170
227
|
this.#widget = options.widget;
|
|
171
228
|
this.#model = options.model;
|
|
172
229
|
this.#exports = options.exports;
|
|
173
230
|
this.#controller = options.controller;
|
|
174
231
|
this.#createHost = options.createHost;
|
|
232
|
+
this.#onDestroy = options.onDestroy;
|
|
175
233
|
}
|
|
176
234
|
|
|
177
235
|
/**
|
|
@@ -185,76 +243,94 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
185
243
|
model,
|
|
186
244
|
createHost,
|
|
187
245
|
controller = new AbortController(),
|
|
246
|
+
onDestroy,
|
|
188
247
|
}: CreateWidgetBindingOptions<T>): Promise<WidgetBinding<T>> {
|
|
189
248
|
const signal = controller.signal;
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
let lateCleanup: Promise<void> | undefined;
|
|
250
|
+
try {
|
|
251
|
+
const widget: ResolvedWidget<T> =
|
|
252
|
+
typeof widgetDef === "function" ? await widgetDef() : widgetDef;
|
|
192
253
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
254
|
+
if (signal.aborted) {
|
|
255
|
+
throw new Error("[anywidget] binding destroyed");
|
|
256
|
+
}
|
|
257
|
+
const initPromise = Promise.resolve(
|
|
258
|
+
widget.initialize?.({
|
|
259
|
+
model: modelProxy(model, signal),
|
|
260
|
+
experimental,
|
|
261
|
+
signal,
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
// If destroyed mid-initialize, still run a late-arriving legacy
|
|
265
|
+
// cleanup callback: the widget acquired resources.
|
|
266
|
+
lateCleanup = initPromise
|
|
267
|
+
.then(async (settled) => {
|
|
268
|
+
if (signal.aborted && isCleanup(settled)) {
|
|
269
|
+
await safelyRunCleanup(settled, "late initialize");
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
.catch(() => undefined);
|
|
209
273
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
274
|
+
// Race against destruction so a hung `initialize` can't strand
|
|
275
|
+
// callers whose model already closed.
|
|
276
|
+
const result = await Promise.race([
|
|
277
|
+
initPromise,
|
|
278
|
+
new Promise<never>((_, reject) => {
|
|
279
|
+
const abort = () =>
|
|
280
|
+
reject(new Error("[anywidget] binding destroyed"));
|
|
281
|
+
if (signal.aborted) {
|
|
282
|
+
abort();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
286
|
+
}),
|
|
287
|
+
]);
|
|
223
288
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
289
|
+
if (signal.aborted) {
|
|
290
|
+
throw new Error("[anywidget] binding destroyed");
|
|
291
|
+
}
|
|
227
292
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
293
|
+
// Distinguish anywidget's three return shapes:
|
|
294
|
+
// function → legacy cleanup callback (run on destroy)
|
|
295
|
+
// object → widget exports (anywidget>=0.11)
|
|
296
|
+
// void → nothing
|
|
297
|
+
let exports: unknown;
|
|
298
|
+
let initializeCleanup: Cleanup | undefined;
|
|
299
|
+
if (isCleanup(result)) {
|
|
300
|
+
initializeCleanup = result;
|
|
301
|
+
exports = undefined;
|
|
302
|
+
} else if (typeof result === "object" && result !== null) {
|
|
303
|
+
exports = result;
|
|
304
|
+
} else {
|
|
305
|
+
exports = undefined;
|
|
306
|
+
}
|
|
242
307
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
308
|
+
const binding = new WidgetBinding({
|
|
309
|
+
widget,
|
|
310
|
+
model,
|
|
311
|
+
exports,
|
|
312
|
+
controller,
|
|
313
|
+
createHost,
|
|
314
|
+
onDestroy,
|
|
315
|
+
});
|
|
316
|
+
if (initializeCleanup) {
|
|
317
|
+
signal.addEventListener(
|
|
318
|
+
"abort",
|
|
319
|
+
() => void binding.#trackCleanup(initializeCleanup, "initialize"),
|
|
320
|
+
{ once: true },
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return binding;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
// A canceled initialize may still be using import.meta.url in its
|
|
326
|
+
// late cleanup. Keep the module alive until that work settles.
|
|
327
|
+
if (lateCleanup) {
|
|
328
|
+
void lateCleanup.then(() => onDestroy?.());
|
|
329
|
+
} else {
|
|
330
|
+
onDestroy?.();
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
256
333
|
}
|
|
257
|
-
return binding;
|
|
258
334
|
}
|
|
259
335
|
|
|
260
336
|
/**
|
|
@@ -353,6 +429,8 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
353
429
|
while (this.#cleanupTasks.size > 0) {
|
|
354
430
|
await Promise.all(this.#cleanupTasks);
|
|
355
431
|
}
|
|
432
|
+
this.#onDestroy?.();
|
|
433
|
+
this.#onDestroy = undefined;
|
|
356
434
|
}
|
|
357
435
|
}
|
|
358
436
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
/* oxlint-disable typescript/no-explicit-any */
|
|
3
3
|
|
|
4
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { useEventListener } from "@/hooks/useEventListener";
|
|
7
7
|
import { createPlugin } from "@/plugins/core/builder";
|
|
@@ -186,12 +186,14 @@ const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
186
186
|
// the figure is built once, and switching to a new model is the rebind
|
|
187
187
|
// effect's job, not a reason to tear the canvas down.
|
|
188
188
|
const modelIdRef = useRef(modelId);
|
|
189
|
-
modelIdRef.current = modelId;
|
|
190
189
|
// The data attributes are re-parsed into fresh objects on every rerun, so
|
|
191
190
|
// `toolbarImages` changes identity even when its contents do not. Read it
|
|
192
191
|
// from a ref so it can't retrigger the mount effect and rebuild the canvas.
|
|
193
192
|
const toolbarImagesRef = useRef(toolbarImages);
|
|
194
|
-
|
|
193
|
+
useLayoutEffect(() => {
|
|
194
|
+
modelIdRef.current = modelId;
|
|
195
|
+
toolbarImagesRef.current = toolbarImages;
|
|
196
|
+
}, [modelId, toolbarImages]);
|
|
195
197
|
|
|
196
198
|
// Bind the already-rendered figure/socket to a backend model, leaving the
|
|
197
199
|
// DOM, figure, and socket in place so they survive across reruns. Disposes
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
/* oxlint-disable typescript/no-explicit-any */
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import useEvent from "react-use-event-hook";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { MarimoIncomingMessageEvent } from "@/core/dom/events";
|
|
7
8
|
import {
|
|
@@ -146,17 +147,10 @@ const PanelSlot = (props: Props) => {
|
|
|
146
147
|
> | null>(null);
|
|
147
148
|
const [loaded, setLoaded] = useState<boolean>(false);
|
|
148
149
|
const [rendered, setRendered] = useState<string | null>(null);
|
|
150
|
+
const eventBufferRef = useRef<EventBuffer<unknown> | null>(null);
|
|
149
151
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
functionsRef.current = functions;
|
|
153
|
-
|
|
154
|
-
const processEvents = useCallback(() => {
|
|
155
|
-
if (!eventBufferRef.current) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const events = eventBufferRef.current.getAndClear();
|
|
152
|
+
const processEvents = useEvent(() => {
|
|
153
|
+
const events = eventBufferRef.current?.getAndClear() ?? [];
|
|
160
154
|
if (events.length === 0) {
|
|
161
155
|
return;
|
|
162
156
|
}
|
|
@@ -182,14 +176,19 @@ const PanelSlot = (props: Props) => {
|
|
|
182
176
|
);
|
|
183
177
|
const buffers: ArrayBuffer[] = [];
|
|
184
178
|
message.content = extractBuffers(message.content, buffers);
|
|
185
|
-
|
|
179
|
+
functions.send_to_widget({ message, buffers }).catch((error) => {
|
|
186
180
|
Logger.warn("Failed to send Panel event to backend", error);
|
|
187
181
|
});
|
|
188
|
-
}
|
|
182
|
+
});
|
|
189
183
|
|
|
190
|
-
|
|
191
|
-
new EventBuffer(processEvents)
|
|
192
|
-
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const buffer = new EventBuffer(processEvents);
|
|
186
|
+
eventBufferRef.current = buffer;
|
|
187
|
+
return () => {
|
|
188
|
+
buffer.dispose();
|
|
189
|
+
eventBufferRef.current = null;
|
|
190
|
+
};
|
|
191
|
+
}, [processEvents]);
|
|
193
192
|
|
|
194
193
|
// Load the bokeh extension
|
|
195
194
|
useEffect(() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { EventBuffer, extractBuffers, MessageSchema } from "../utils";
|
|
4
4
|
|
|
5
5
|
describe("MessageSchema", () => {
|
|
@@ -73,6 +73,44 @@ describe("EventBuffer", () => {
|
|
|
73
73
|
eventBuffer = new EventBuffer(processEvents, 50);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
eventBuffer.dispose();
|
|
78
|
+
vi.useRealTimers();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("flushes queued events and cancels the pending timer on dispose", () => {
|
|
82
|
+
eventBuffer.add("event1");
|
|
83
|
+
eventBuffer.add("event2");
|
|
84
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
85
|
+
expect(processEvents).toHaveBeenCalledOnce();
|
|
86
|
+
|
|
87
|
+
eventBuffer.dispose();
|
|
88
|
+
expect(processEvents).toHaveBeenCalledTimes(2);
|
|
89
|
+
eventBuffer.dispose();
|
|
90
|
+
|
|
91
|
+
expect(eventBuffer.size()).toBe(0);
|
|
92
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
93
|
+
vi.advanceTimersByTime(100);
|
|
94
|
+
expect(processEvents).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not process an empty buffer on dispose", () => {
|
|
98
|
+
eventBuffer.dispose();
|
|
99
|
+
expect(processEvents).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resets the blocked state when disposed", () => {
|
|
103
|
+
eventBuffer.add("event1");
|
|
104
|
+
// Emulate a real processEvents, which drains the buffer
|
|
105
|
+
eventBuffer.getAndClear();
|
|
106
|
+
eventBuffer.dispose();
|
|
107
|
+
eventBuffer.add("event2");
|
|
108
|
+
|
|
109
|
+
expect(processEvents).toHaveBeenCalledTimes(2);
|
|
110
|
+
expect(eventBuffer.getAndClear()).toEqual(["event2"]);
|
|
111
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
76
114
|
it("should add events to buffer", () => {
|
|
77
115
|
eventBuffer.add("event1");
|
|
78
116
|
eventBuffer.add("event2");
|
|
@@ -83,6 +83,7 @@ export class EventBuffer<T> {
|
|
|
83
83
|
clearTimeout(this.timeout);
|
|
84
84
|
}
|
|
85
85
|
this.timeout = window.setTimeout(() => {
|
|
86
|
+
this.timeout = null;
|
|
86
87
|
this.isBlocked = false;
|
|
87
88
|
if (this.buffer.length > 0) {
|
|
88
89
|
this.flush();
|
|
@@ -98,6 +99,22 @@ export class EventBuffer<T> {
|
|
|
98
99
|
this.buffer = [];
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Cancel the pending timer, flushing any events still held by the block
|
|
104
|
+
* window so the last user interaction is not lost.
|
|
105
|
+
*/
|
|
106
|
+
public dispose() {
|
|
107
|
+
if (this.timeout !== null) {
|
|
108
|
+
clearTimeout(this.timeout);
|
|
109
|
+
this.timeout = null;
|
|
110
|
+
}
|
|
111
|
+
this.isBlocked = false;
|
|
112
|
+
if (this.buffer.length > 0) {
|
|
113
|
+
this.processEvents();
|
|
114
|
+
}
|
|
115
|
+
this.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
101
118
|
public getAndClear() {
|
|
102
119
|
const events = this.buffer;
|
|
103
120
|
this.buffer = [];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { composeStories } from "@storybook/react-vite";
|
|
4
|
+
import { act, render } from "@testing-library/react";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import * as stories from "../editor.stories";
|
|
7
|
+
|
|
8
|
+
vi.mock("@/core/codemirror/cm", () => ({
|
|
9
|
+
basicBundle: () => [],
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { Primary } = composeStories(stories);
|
|
13
|
+
|
|
14
|
+
it("preserves editor changes when the story rerenders with the same theme", () => {
|
|
15
|
+
const { container, rerender } = render(<Primary />);
|
|
16
|
+
const editor = container.querySelector<HTMLElement>(".cm-editor");
|
|
17
|
+
expect(editor).not.toBeNull();
|
|
18
|
+
const view = editor && EditorView.findFromDOM(editor);
|
|
19
|
+
expect(view).toBeDefined();
|
|
20
|
+
if (!view) {
|
|
21
|
+
throw new Error("Editor view was not mounted");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
view.dispatch({
|
|
26
|
+
changes: { from: 0, to: view.state.doc.length, insert: "user edit" },
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
rerender(<Primary />);
|
|
30
|
+
|
|
31
|
+
expect(container.querySelector(".cm-editor")).toBe(editor);
|
|
32
|
+
expect(view.state.doc.toString()).toBe("user edit");
|
|
33
|
+
});
|
|
@@ -5,7 +5,7 @@ import { EditorState, type Extension } from "@codemirror/state";
|
|
|
5
5
|
import { EditorView } from "@codemirror/view";
|
|
6
6
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
7
7
|
import CodeMirror from "@uiw/react-codemirror";
|
|
8
|
-
import { useEffect, useRef } from "react";
|
|
8
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
9
9
|
import { CopilotConfig } from "@/core/codemirror/copilot/copilot-config";
|
|
10
10
|
import { copilotBundle } from "@/core/codemirror/copilot/extension";
|
|
11
11
|
import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
|
|
@@ -52,28 +52,35 @@ const Editor = (opts: { extensions?: Extension[] }): React.ReactNode => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
return () => view.destroy();
|
|
55
|
-
|
|
56
|
-
}, [ref.current]);
|
|
55
|
+
}, [opts.extensions]);
|
|
57
56
|
|
|
58
57
|
return <div className="cm" ref={ref} />;
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
const PrimaryEditor = ({ theme }: Pick<CodeMirrorSetupOpts, "theme">) => {
|
|
61
|
+
const extensions = useMemo(
|
|
62
|
+
() =>
|
|
63
|
+
basicBundle({
|
|
64
|
+
completionConfig: {
|
|
65
|
+
activate_on_typing: false,
|
|
66
|
+
copilot: false,
|
|
67
|
+
signature_hint_on_typing: false,
|
|
68
|
+
},
|
|
69
|
+
theme,
|
|
70
|
+
hotkeys: new OverridingHotkeyProvider({}),
|
|
71
|
+
} as unknown as CodeMirrorSetupOpts),
|
|
72
|
+
[theme],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
63
76
|
<div className="Cell m-20 w-[60%] overflow-hidden">
|
|
64
|
-
<Editor
|
|
65
|
-
extensions={basicBundle({
|
|
66
|
-
completionConfig: {
|
|
67
|
-
activate_on_typing: false,
|
|
68
|
-
copilot: false,
|
|
69
|
-
signature_hint_on_typing: false,
|
|
70
|
-
},
|
|
71
|
-
theme: ctx.globals.theme,
|
|
72
|
-
hotkeys: new OverridingHotkeyProvider({}),
|
|
73
|
-
} as unknown as CodeMirrorSetupOpts)}
|
|
74
|
-
/>
|
|
77
|
+
<Editor extensions={extensions} />
|
|
75
78
|
</div>
|
|
76
|
-
)
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Primary: Story = {
|
|
83
|
+
render: (_args, ctx) => <PrimaryEditor theme={ctx.globals.theme} />,
|
|
77
84
|
};
|
|
78
85
|
|
|
79
86
|
export const DefaultPython: Story = {
|