@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.
Files changed (36) hide show
  1. package/dist/{ErrorBoundary-B4QYAM0I.js → ErrorBoundary-DHfwqB0K.js} +1 -1
  2. package/dist/{code-visibility-BbrgXRMG.js → code-visibility-5llx6KCK.js} +1 -1
  3. package/dist/{common-DPSFvO_K.js → common-DBC1NAar.js} +89 -61
  4. package/dist/{data-grid-overlay-editor-D0m9lgVY.js → data-grid-overlay-editor-D01HzlXg.js} +1 -1
  5. package/dist/{feedback-button-BdqMy9oz.js → feedback-button-gTC25Y-p.js} +2 -2
  6. package/dist/{glide-data-editor-blkbnr2a.js → glide-data-editor-BXXphavz.js} +3 -3
  7. package/dist/{globals-CvaBSmzH.js → globals-BcxqBRmX.js} +1 -1
  8. package/dist/main.js +1491 -1403
  9. package/dist/{number-overlay-editor-yOSMD6Af.js → number-overlay-editor-CaspWg5p.js} +1 -1
  10. package/dist/{reveal-component-BHOMrvDj.js → reveal-component-CWk_paD2.js} +2 -2
  11. package/package.json +1 -1
  12. package/src/components/chat/__tests__/message-queue.test.tsx +17 -0
  13. package/src/components/chat/chat-panel.tsx +3 -7
  14. package/src/components/chat/chat-utils.ts +4 -8
  15. package/src/components/data-table/__tests__/context-menu-interaction.test.tsx +130 -0
  16. package/src/components/data-table/context-menu.tsx +36 -20
  17. package/src/components/editor/cell/CreateCellButton.tsx +8 -10
  18. package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +54 -0
  19. package/src/components/editor/cell/code/cell-editor.tsx +1 -1
  20. package/src/components/editor/cell/code/language-toggle.tsx +8 -7
  21. package/src/components/editor/file-tree/file-viewer.tsx +4 -2
  22. package/src/components/editor/output/console/ConsoleOutput.tsx +8 -18
  23. package/src/components/slides/slide-cell-view.tsx +1 -1
  24. package/src/core/static/files.ts +2 -0
  25. package/src/hooks/useInputHistory.ts +4 -7
  26. package/src/hooks/useResizeObserver.ts +4 -7
  27. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +2 -0
  28. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +241 -2
  29. package/src/plugins/impl/anywidget/runtime.ts +21 -9
  30. package/src/plugins/impl/anywidget/widget-binding.ts +160 -82
  31. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +5 -3
  32. package/src/plugins/impl/panel/PanelPlugin.tsx +15 -16
  33. package/src/plugins/impl/panel/__tests__/utils.test.ts +39 -1
  34. package/src/plugins/impl/panel/utils.ts +17 -0
  35. package/src/stories/__tests__/editor-story.test.tsx +33 -0
  36. package/src/stories/editor.stories.tsx +24 -17
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { RefObject } from "react";
8
8
  import { useEffect, useRef } from "react";
9
+ import useEvent from "react-use-event-hook";
9
10
 
10
11
  interface Size {
11
12
  width: number | undefined;
@@ -30,9 +31,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
30
31
  const { ref, box = "content-box", skip } = options;
31
32
  // oxlint-disable-next-line react/hook-use-state
32
33
  const previousSize = useRef<Size>({ ...initialSize });
33
- const onResize = useRef<((size: Size) => void) | undefined>(undefined);
34
- // oxlint-disable-next-line react-hooks/react-compiler
35
- onResize.current = options.onResize;
34
+ const onResize = useEvent((size: Size) => options.onResize?.(size));
36
35
 
37
36
  useEffect(() => {
38
37
  if (!ref.current || skip) {
@@ -63,9 +62,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
63
62
  previousSize.current.width = newWidth;
64
63
  previousSize.current.height = newHeight;
65
64
 
66
- if (onResize.current) {
67
- onResize.current(newSize);
68
- }
65
+ onResize(newSize);
69
66
  }
70
67
  });
71
68
 
@@ -74,7 +71,7 @@ export function useResizeObserver<T extends HTMLElement = HTMLElement>(
74
71
  return () => {
75
72
  observer.disconnect();
76
73
  };
77
- }, [box, ref, skip]);
74
+ }, [box, ref, skip, onResize]);
78
75
  }
79
76
 
80
77
  type BoxSizesKey = keyof Pick<
@@ -238,6 +238,7 @@ describe("WidgetRegistry.getWidget", () => {
238
238
  jsUrl: SPEC.url,
239
239
  jsHash: SPEC.hash,
240
240
  kernelAuthored: true,
241
+ lifetime: expect.any(AbortSignal),
241
242
  });
242
243
  expect(widgetDef.initialize).toHaveBeenCalledTimes(1);
243
244
  expect(widget.exports).toBe(exports);
@@ -263,6 +264,7 @@ describe("WidgetRegistry.getWidget", () => {
263
264
  jsUrl: SPEC.url,
264
265
  jsHash: SPEC.hash,
265
266
  kernelAuthored: true,
267
+ lifetime: expect.any(AbortSignal),
266
268
  });
267
269
  });
268
270
 
@@ -7,9 +7,12 @@ import { userConfigAtom } from "@/core/config/config";
7
7
  import { parseUserConfig } from "@/core/config/config-schema";
8
8
  import { initialModeAtom } from "@/core/mode";
9
9
  import { store } from "@/core/state/jotai";
10
+ import type { DataURLString } from "@/utils/json/base64";
10
11
  import { Model } from "../model";
11
12
  import type { Host } from "../host";
12
- import type { ModelState } from "../types";
13
+ import type { ModelState, WidgetModelId } from "../types";
14
+ import { WidgetRuntime } from "../runtime";
15
+ import { Deferred } from "@/utils/Deferred";
13
16
  import { visibleForTesting } from "../widget-binding";
14
17
 
15
18
  const { WidgetDefRegistry, WidgetBinding } = visibleForTesting;
@@ -30,13 +33,14 @@ const createTestHost = (): Host => ({
30
33
  function createBinding<T extends ModelState>(
31
34
  widgetDef: AnyWidget<T>,
32
35
  model: Model<T>,
33
- options: { controller?: AbortController } = {},
36
+ options: { controller?: AbortController; onDestroy?: () => void } = {},
34
37
  ) {
35
38
  return WidgetBinding.create({
36
39
  widgetDef,
37
40
  model,
38
41
  createHost: createTestHost,
39
42
  controller: options.controller,
43
+ onDestroy: options.onDestroy,
40
44
  });
41
45
  }
42
46
 
@@ -123,6 +127,25 @@ describe("WidgetDefRegistry", () => {
123
127
  promise2.catch(() => undefined);
124
128
  });
125
129
 
130
+ it("does not evict a replacement when an invalidated import fails", async () => {
131
+ const oldImport = getModule(
132
+ registry,
133
+ "http://localhost/old.js",
134
+ "replaced",
135
+ );
136
+ registry.invalidate("replaced");
137
+ const newImport = registry.getModule({
138
+ jsUrl: "data:text/javascript,export default {}",
139
+ jsHash: "replaced",
140
+ kernelAuthored: true,
141
+ });
142
+ await expect(oldImport).rejects.toThrow(/untrusted/i);
143
+ await newImport;
144
+ expect(getModule(registry, "http://localhost/new.js", "replaced")).toBe(
145
+ newImport,
146
+ );
147
+ });
148
+
126
149
  describe("URL validation", () => {
127
150
  it.each([
128
151
  // Attack vector: raw <marimo-anywidget data-js-url=...> in markdown
@@ -148,9 +171,225 @@ describe("WidgetDefRegistry", () => {
148
171
  ).rejects.not.toThrow(/untrusted/i);
149
172
  });
150
173
  });
174
+
175
+ describe("static module URLs", () => {
176
+ afterEach(() => {
177
+ delete window.__MARIMO_STATIC__;
178
+ vi.restoreAllMocks();
179
+ });
180
+
181
+ it("releases the generated URL when a static module fails to import", async () => {
182
+ window.__MARIMO_STATIC__ = {
183
+ files: {
184
+ "/@file/widget.js":
185
+ "data:text/javascript;base64,ZXhwb3J0IHt9" as DataURLString,
186
+ },
187
+ };
188
+ const create = vi
189
+ .spyOn(URL, "createObjectURL")
190
+ .mockReturnValue("blob:widget-module");
191
+ const revoke = vi
192
+ .spyOn(URL, "revokeObjectURL")
193
+ .mockImplementation(() => undefined);
194
+
195
+ await expect(
196
+ getModule(registry, "./@file/widget.js", "static-module"),
197
+ ).rejects.toThrow(/blob:/);
198
+
199
+ expect(create).toHaveBeenCalledOnce();
200
+ expect(revoke).toHaveBeenCalledExactlyOnceWith("blob:widget-module");
201
+ });
202
+
203
+ it("does not revoke active modules when their cache entry is invalidated", async () => {
204
+ window.__MARIMO_STATIC__ = {
205
+ files: {
206
+ "/@file/widget.js":
207
+ "data:text/javascript;base64,ZXhwb3J0IHt9" as DataURLString,
208
+ },
209
+ };
210
+ const oldUrl = "data:text/javascript,export const version = 1";
211
+ const newUrl = "data:text/javascript,export const version = 2";
212
+ vi.spyOn(URL, "createObjectURL")
213
+ .mockReturnValueOnce(oldUrl)
214
+ .mockReturnValueOnce(newUrl);
215
+ const revoke = vi
216
+ .spyOn(URL, "revokeObjectURL")
217
+ .mockImplementation(() => undefined);
218
+ const oldLifetime = new AbortController();
219
+ const newLifetime = new AbortController();
220
+ const options = { jsUrl: "./@file/widget.js", jsHash: "invalidated" };
221
+ const oldImport = registry.getModule({
222
+ ...options,
223
+ lifetime: oldLifetime.signal,
224
+ });
225
+ registry.invalidate(options.jsHash);
226
+ const newImport = registry.getModule({
227
+ ...options,
228
+ lifetime: newLifetime.signal,
229
+ });
230
+ await Promise.all([oldImport, newImport]);
231
+ expect(revoke).not.toHaveBeenCalled();
232
+ oldLifetime.abort();
233
+ await vi.waitFor(() =>
234
+ expect(revoke).toHaveBeenCalledExactlyOnceWith(oldUrl),
235
+ );
236
+ const siblingLifetime = new AbortController();
237
+ expect(
238
+ registry.getModule({ ...options, lifetime: siblingLifetime.signal }),
239
+ ).toBe(newImport);
240
+ newLifetime.abort();
241
+ siblingLifetime.abort();
242
+ await vi.waitFor(() => expect(revoke).toHaveBeenLastCalledWith(newUrl));
243
+ });
244
+
245
+ it("waits for a canceled import to settle before revoking its URL", async () => {
246
+ window.__MARIMO_STATIC__ = {
247
+ files: {
248
+ "/@file/widget.js":
249
+ "data:text/javascript;base64,ZXhwb3J0IHt9" as DataURLString,
250
+ },
251
+ };
252
+ const moduleUrl = "data:text/javascript,export const pending = true";
253
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(moduleUrl);
254
+ const revoke = vi
255
+ .spyOn(URL, "revokeObjectURL")
256
+ .mockImplementation(() => undefined);
257
+ const lifetime = new AbortController();
258
+ const pending = registry.getModule({
259
+ jsUrl: "./@file/widget.js",
260
+ jsHash: "pending",
261
+ lifetime: lifetime.signal,
262
+ });
263
+ lifetime.abort();
264
+ expect(revoke).not.toHaveBeenCalled();
265
+ await pending;
266
+ expect(revoke).toHaveBeenCalledExactlyOnceWith(moduleUrl);
267
+ });
268
+
269
+ it("does not revoke an unchanged URL for a missing static file", async () => {
270
+ window.__MARIMO_STATIC__ = { files: {} };
271
+ const revoke = vi
272
+ .spyOn(URL, "revokeObjectURL")
273
+ .mockImplementation(() => undefined);
274
+
275
+ await expect(
276
+ getModule(registry, "./@file/missing.js", "missing-module"),
277
+ ).rejects.toThrow(/http:/);
278
+
279
+ expect(revoke).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("keeps a shared module URL until the last widget generation is destroyed", async () => {
283
+ window.__MARIMO_STATIC__ = {
284
+ files: {
285
+ "/@file/widget.js":
286
+ "data:text/javascript;base64,ZXhwb3J0IHt9" as DataURLString,
287
+ },
288
+ };
289
+ // Node cannot import blob URLs; a data URL exercises successful loading.
290
+ const moduleUrl = `data:text/javascript,${encodeURIComponent(
291
+ "export default { initialize: () => ({ moduleUrl: import.meta.url }) }",
292
+ )}`;
293
+ vi.spyOn(URL, "createObjectURL").mockReturnValue(moduleUrl);
294
+ const revoke = vi
295
+ .spyOn(URL, "revokeObjectURL")
296
+ .mockImplementation(() => undefined);
297
+
298
+ const createRuntime = (id: string) => {
299
+ const runtime = new WidgetRuntime(id as WidgetModelId, {
300
+ timeout: 50,
301
+ isEditMode: () => true,
302
+ createHost: createTestHost,
303
+ onModelTimeout: vi.fn(),
304
+ });
305
+ runtime.setModel(new Model({}, createMockComm()));
306
+ runtime.setSpec({ url: "./@file/widget.js", hash: "static-success" });
307
+ return runtime;
308
+ };
309
+ const first = createRuntime("first");
310
+ const second = createRuntime("second");
311
+ const widgets = await Promise.all([
312
+ first.getWidget(),
313
+ second.getWidget(),
314
+ ]);
315
+ expect(widgets.map((widget) => widget.exports)).toEqual([
316
+ { moduleUrl },
317
+ { moduleUrl },
318
+ ]);
319
+ expect(URL.createObjectURL).toHaveBeenCalledOnce();
320
+ expect(revoke).not.toHaveBeenCalled();
321
+
322
+ first.dispose();
323
+ await new Promise((resolve) => setTimeout(resolve, 0));
324
+ expect(revoke).not.toHaveBeenCalled();
325
+
326
+ second.dispose();
327
+ await vi.waitFor(() => {
328
+ expect(revoke).toHaveBeenCalledExactlyOnceWith(moduleUrl);
329
+ });
330
+
331
+ // A later generation reuses the evaluated module rather than
332
+ // re-running its top-level side effects, and does not revoke again.
333
+ const third = createRuntime("third");
334
+ const widget = await third.getWidget();
335
+ expect(widget.exports).toEqual({ moduleUrl });
336
+ expect(URL.createObjectURL).toHaveBeenCalledOnce();
337
+ third.dispose();
338
+ await new Promise((resolve) => setTimeout(resolve, 0));
339
+ expect(revoke).toHaveBeenCalledOnce();
340
+ });
341
+ });
151
342
  });
152
343
 
153
344
  describe("WidgetBinding", () => {
345
+ it("releases module resources after asynchronous generation cleanup", async () => {
346
+ const cleanup = new Deferred<void>();
347
+ const onDestroy = vi.fn();
348
+ const binding = await createBinding(
349
+ { initialize: () => () => cleanup.promise },
350
+ new Model({}, createMockComm()),
351
+ { onDestroy },
352
+ );
353
+ const destroying = binding.destroy();
354
+ expect(onDestroy).not.toHaveBeenCalled();
355
+ cleanup.resolve();
356
+ await destroying;
357
+ await binding.destroy();
358
+ expect(onDestroy).toHaveBeenCalledOnce();
359
+ });
360
+
361
+ it("retains module resources until canceled initialization and late cleanup settle", async () => {
362
+ const initialize = new Deferred<() => Promise<void>>();
363
+ const cleanup = new Deferred<void>();
364
+ const controller = new AbortController();
365
+ const onDestroy = vi.fn();
366
+ const pending = createBinding(
367
+ { initialize: () => initialize.promise },
368
+ new Model({}, createMockComm()),
369
+ { controller, onDestroy },
370
+ );
371
+ controller.abort();
372
+ await expect(pending).rejects.toThrow(/binding destroyed/);
373
+ expect(onDestroy).not.toHaveBeenCalled();
374
+ initialize.resolve(() => cleanup.promise);
375
+ await Promise.resolve();
376
+ expect(onDestroy).not.toHaveBeenCalled();
377
+ cleanup.resolve();
378
+ await vi.waitFor(() => expect(onDestroy).toHaveBeenCalledOnce());
379
+ });
380
+
381
+ it("releases module resources when initialization fails", async () => {
382
+ const onDestroy = vi.fn();
383
+ await expect(
384
+ createBinding(
385
+ { initialize: () => Promise.reject(new Error("initialize failed")) },
386
+ new Model({}, createMockComm()),
387
+ { onDestroy },
388
+ ),
389
+ ).rejects.toThrow("initialize failed");
390
+ await vi.waitFor(() => expect(onDestroy).toHaveBeenCalledOnce());
391
+ });
392
+
154
393
  it("should run initialize exactly once and expose exports", async () => {
155
394
  const model = new Model<ModelState>({ count: 0 }, createMockComm());
156
395
  const exports = { setValue: vi.fn() };
@@ -206,21 +206,33 @@ export class WidgetRuntime {
206
206
  controller: AbortController;
207
207
  }): Promise<WidgetBinding<ModelState>> {
208
208
  const { spec, model, controller } = options;
209
- const mod = await WIDGET_DEF_REGISTRY.getModule({
210
- jsUrl: spec.url,
211
- jsHash: spec.hash,
212
- kernelAuthored: true,
213
- });
214
- const widget = resolveAnyWidget(mod, spec.url);
215
- if (!widget) {
216
- WIDGET_DEF_REGISTRY.invalidate(spec.hash);
217
- throw getInvalidAnyWidgetModuleError(mod, spec.url);
209
+ const moduleLifetime = new AbortController();
210
+ let widget: NonNullable<ReturnType<typeof resolveAnyWidget>>;
211
+ try {
212
+ const mod = await WIDGET_DEF_REGISTRY.getModule({
213
+ jsUrl: spec.url,
214
+ jsHash: spec.hash,
215
+ kernelAuthored: true,
216
+ lifetime: moduleLifetime.signal,
217
+ });
218
+ const resolved = resolveAnyWidget(mod, spec.url);
219
+ if (!resolved) {
220
+ WIDGET_DEF_REGISTRY.invalidate(spec.hash);
221
+ throw getInvalidAnyWidgetModuleError(mod, spec.url);
222
+ }
223
+ widget = resolved;
224
+ } catch (error) {
225
+ moduleLifetime.abort();
226
+ throw error;
218
227
  }
228
+ // From here WidgetBinding owns release, including late cleanup when the
229
+ // generation is destroyed during initialize.
219
230
  return WidgetBinding.create({
220
231
  widgetDef: widget,
221
232
  model,
222
233
  createHost: this.#createHost,
223
234
  controller,
235
+ onDestroy: () => moduleLifetime.abort(),
224
236
  });
225
237
  }
226
238