@marimo-team/frontend 0.25.3-dev46 → 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/assets/{JsonOutput-DsMCKg5f.js → JsonOutput-53CM4rZw.js} +1 -1
- package/dist/assets/{add-cell-with-ai-CkjslziO.js → add-cell-with-ai-DQlEdS75.js} +1 -1
- package/dist/assets/{agent-panel-DVUZ4l17.js → agent-panel-Dvr9HVds.js} +1 -1
- package/dist/assets/{cell-editor-BXMAe73i.js → cell-editor-B9xsMBv-.js} +2 -2
- package/dist/assets/{chat-display-BrcwdhKJ.js → chat-display-C3__xzM4.js} +1 -1
- package/dist/assets/chat-panel-CffQmmt6.js +4 -0
- package/dist/assets/{chat-ui-NlBK3kK6.js → chat-ui-TDKs3kxj.js} +1 -1
- package/dist/assets/{column-preview-D-MQXrz3.js → column-preview-8r_KX_X9.js} +1 -1
- package/dist/assets/{command-palette-BbCoQAH7.js → command-palette-CymnpCiy.js} +1 -1
- package/dist/assets/{edit-page-OQUBqPxV.js → edit-page-CWjREE5g.js} +6 -6
- package/dist/assets/file-explorer-panel-DRj-91dB.js +1 -0
- package/dist/assets/{hooks-B2jmXrjG.js → hooks-CXfUfh2H.js} +1 -1
- package/dist/assets/{index-CnvKSoR0.js → index-yFM8bRve.js} +8 -8
- package/dist/assets/{layout-Ckm5arvO.js → layout-BDFQESY4.js} +3 -3
- package/dist/assets/{panels-B2HvGLkD.js → panels-Ky-lM0iL.js} +1 -1
- package/dist/assets/{reveal-component-CxQ9fVAB.js → reveal-component-DaU9bJyq.js} +37 -37
- package/dist/assets/{run-page-DtWhVRu3.js → run-page-5-VklWLa.js} +1 -1
- package/dist/assets/{scratchpad-panel-DsqDHGr7.js → scratchpad-panel-DVHXbcng.js} +1 -1
- package/dist/assets/{session-panel-CjJ0nwoT.js → session-panel-CPVhiI4m.js} +1 -1
- package/dist/assets/{state-BHuYDaxs.js → state-D7Y-ORmT.js} +1 -1
- package/dist/assets/useBoolean-DGV7FfNx.js +1 -0
- package/dist/assets/{useNotebookActions-BTkKQpGk.js → useNotebookActions-CqqHJtAd.js} +1 -1
- package/dist/index.html +5 -5
- 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
- package/dist/assets/chat-panel-C__3iSyI.js +0 -4
- package/dist/assets/file-explorer-panel-Cg48iuPg.js +0 -1
- package/dist/assets/useBoolean-j0qHaJxu.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { DatabaseIcon, DiamondPlusIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
4
|
import { Button } from "@/components/editor/inputs/Inputs";
|
|
5
5
|
import { MinimalHotkeys } from "@/components/shortcuts/renderShortcut";
|
|
6
6
|
import {
|
|
@@ -36,7 +36,8 @@ export const CreateCellButton = ({
|
|
|
36
36
|
const { createNewCell, addSetupCellIfDoesntExist } = useCellActions();
|
|
37
37
|
const shortcut = `${oneClickShortcut}-Click`;
|
|
38
38
|
const [open, setOpen] = useState(false);
|
|
39
|
-
|
|
39
|
+
// Time the dropdown last opened, to ignore the click that opened it
|
|
40
|
+
const openedAt = useRef(0);
|
|
40
41
|
|
|
41
42
|
const baseTooltipContent =
|
|
42
43
|
getConnectionTooltip(connectionState) || tooltipContent;
|
|
@@ -83,9 +84,7 @@ export const CreateCellButton = ({
|
|
|
83
84
|
|
|
84
85
|
const openDropdown = () => {
|
|
85
86
|
setOpen(true);
|
|
86
|
-
|
|
87
|
-
// Allow interactions after a brief delay to prevent the dropdown items immediately being clicked
|
|
88
|
-
setTimeout(() => setJustOpened(false), 200);
|
|
87
|
+
openedAt.current = Date.now();
|
|
89
88
|
};
|
|
90
89
|
|
|
91
90
|
// We use onPointerDownCapture (not onPointerDown) to intercept events in
|
|
@@ -121,15 +120,14 @@ export const CreateCellButton = ({
|
|
|
121
120
|
const handleOpenChange = (isOpen: boolean) => {
|
|
122
121
|
setOpen(isOpen);
|
|
123
122
|
if (isOpen) {
|
|
124
|
-
|
|
125
|
-
// Allow interactions after a brief delay
|
|
126
|
-
setTimeout(() => setJustOpened(false), 200);
|
|
123
|
+
openedAt.current = Date.now();
|
|
127
124
|
}
|
|
128
125
|
};
|
|
129
126
|
|
|
130
127
|
const handleFirstItemClick = (e: React.MouseEvent) => {
|
|
131
|
-
// Hack to prevent the first item from being clicked when the dropdown is
|
|
132
|
-
|
|
128
|
+
// Hack to prevent the first item from being clicked when the dropdown is
|
|
129
|
+
// opened: allow interactions only after a brief delay
|
|
130
|
+
if (Date.now() - openedAt.current < 200) {
|
|
133
131
|
e.preventDefault();
|
|
134
132
|
e.stopPropagation();
|
|
135
133
|
return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { EditorState } from "@codemirror/state";
|
|
4
|
+
import { EditorView } from "@codemirror/view";
|
|
5
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
6
|
+
import { createRef } from "react";
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
9
|
+
import { switchLanguage } from "@/core/codemirror/language/extension";
|
|
10
|
+
import { LanguageToggle } from "../language-toggle";
|
|
11
|
+
|
|
12
|
+
vi.mock("@/core/codemirror/language/extension", () => ({
|
|
13
|
+
switchLanguage: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("LanguageToggle", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.mocked(switchLanguage).mockClear();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses the editor mounted after the button rendered", () => {
|
|
22
|
+
const editorViewRef = createRef<EditorView>();
|
|
23
|
+
const onAfterToggle = vi.fn();
|
|
24
|
+
render(
|
|
25
|
+
<TooltipProvider>
|
|
26
|
+
<LanguageToggle
|
|
27
|
+
editorViewRef={editorViewRef}
|
|
28
|
+
currentLanguageAdapter="python"
|
|
29
|
+
canSwitchToLanguage={true}
|
|
30
|
+
toType="markdown"
|
|
31
|
+
displayName="Markdown"
|
|
32
|
+
icon="Markdown"
|
|
33
|
+
onAfterToggle={onAfterToggle}
|
|
34
|
+
/>
|
|
35
|
+
</TooltipProvider>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Markdown" }));
|
|
39
|
+
expect(switchLanguage).not.toHaveBeenCalled();
|
|
40
|
+
expect(onAfterToggle).not.toHaveBeenCalled();
|
|
41
|
+
|
|
42
|
+
const view = new EditorView({ state: EditorState.create() });
|
|
43
|
+
editorViewRef.current = view;
|
|
44
|
+
try {
|
|
45
|
+
fireEvent.click(screen.getByRole("button", { name: "Markdown" }));
|
|
46
|
+
expect(switchLanguage).toHaveBeenCalledWith(view, {
|
|
47
|
+
language: "markdown",
|
|
48
|
+
});
|
|
49
|
+
expect(onAfterToggle).toHaveBeenCalledOnce();
|
|
50
|
+
} finally {
|
|
51
|
+
view.destroy();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -572,7 +572,7 @@ const CellEditorInternal = ({
|
|
|
572
572
|
<div className="absolute top-1 right-5">
|
|
573
573
|
<LanguageToggles
|
|
574
574
|
code={code}
|
|
575
|
-
|
|
575
|
+
editorViewRef={editorViewRef}
|
|
576
576
|
currentLanguageAdapter={languageAdapter}
|
|
577
577
|
onAfterToggle={afterToggleMarkdown}
|
|
578
578
|
/>
|
|
@@ -13,7 +13,7 @@ import { Functions } from "@/utils/functions";
|
|
|
13
13
|
import { MarkdownIcon, PythonIcon } from "./icons";
|
|
14
14
|
|
|
15
15
|
interface LanguageTogglesProps {
|
|
16
|
-
|
|
16
|
+
editorViewRef: React.RefObject<EditorView | null>;
|
|
17
17
|
code: string;
|
|
18
18
|
currentLanguageAdapter: LanguageAdapter["type"] | undefined;
|
|
19
19
|
onAfterToggle: () => void;
|
|
@@ -25,7 +25,7 @@ interface LanguageTogglesProps {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
28
|
-
|
|
28
|
+
editorViewRef,
|
|
29
29
|
code,
|
|
30
30
|
currentLanguageAdapter,
|
|
31
31
|
onAfterToggle,
|
|
@@ -43,7 +43,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
43
43
|
return (
|
|
44
44
|
<div className={className}>
|
|
45
45
|
<LanguageToggle
|
|
46
|
-
|
|
46
|
+
editorViewRef={editorViewRef}
|
|
47
47
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
48
48
|
canSwitchToLanguage={canUseSQL && currentLanguageAdapter === "python"}
|
|
49
49
|
icon={
|
|
@@ -58,7 +58,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
58
58
|
onAfterToggle={onAfterToggle}
|
|
59
59
|
/>
|
|
60
60
|
<LanguageToggle
|
|
61
|
-
|
|
61
|
+
editorViewRef={editorViewRef}
|
|
62
62
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
63
63
|
canSwitchToLanguage={
|
|
64
64
|
canUseMarkdown && currentLanguageAdapter === "python"
|
|
@@ -75,7 +75,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
75
75
|
onAfterToggle={onAfterToggle}
|
|
76
76
|
/>
|
|
77
77
|
<LanguageToggle
|
|
78
|
-
|
|
78
|
+
editorViewRef={editorViewRef}
|
|
79
79
|
currentLanguageAdapter={currentLanguageAdapter}
|
|
80
80
|
canSwitchToLanguage={true}
|
|
81
81
|
icon={
|
|
@@ -95,7 +95,7 @@ export const LanguageToggles: React.FC<LanguageTogglesProps> = ({
|
|
|
95
95
|
|
|
96
96
|
interface Props {
|
|
97
97
|
className?: string;
|
|
98
|
-
|
|
98
|
+
editorViewRef: React.RefObject<EditorView | null>;
|
|
99
99
|
canSwitchToLanguage: boolean;
|
|
100
100
|
currentLanguageAdapter: LanguageAdapter["type"] | undefined;
|
|
101
101
|
toType: LanguageAdapter["type"];
|
|
@@ -105,7 +105,7 @@ interface Props {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export const LanguageToggle: React.FC<Props> = ({
|
|
108
|
-
|
|
108
|
+
editorViewRef,
|
|
109
109
|
currentLanguageAdapter,
|
|
110
110
|
canSwitchToLanguage,
|
|
111
111
|
icon,
|
|
@@ -114,6 +114,7 @@ export const LanguageToggle: React.FC<Props> = ({
|
|
|
114
114
|
onAfterToggle,
|
|
115
115
|
}) => {
|
|
116
116
|
const handleClick = () => {
|
|
117
|
+
const editorView = editorViewRef.current;
|
|
117
118
|
if (!editorView) {
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
SaveIcon,
|
|
10
10
|
} from "lucide-react";
|
|
11
11
|
import type React from "react";
|
|
12
|
-
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
13
13
|
import { renderShortcut } from "@/components/shortcuts/renderShortcut";
|
|
14
14
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
15
15
|
import { Button } from "@/components/ui/button";
|
|
@@ -82,7 +82,9 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
|
|
|
82
82
|
// carry no draft to preserve.
|
|
83
83
|
// We use a ref for internalValue so we don't call this effect on each keystroke
|
|
84
84
|
const internalValueRef = useRef<string>(internalValue);
|
|
85
|
-
|
|
85
|
+
useLayoutEffect(() => {
|
|
86
|
+
internalValueRef.current = internalValue;
|
|
87
|
+
}, [internalValue]);
|
|
86
88
|
useEffect(() => {
|
|
87
89
|
return () => {
|
|
88
90
|
if (
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
ChevronsUpDownIcon,
|
|
7
7
|
WrapTextIcon,
|
|
8
8
|
} from "lucide-react";
|
|
9
|
-
import React, { useEffect, useLayoutEffect,
|
|
9
|
+
import React, { useEffect, useLayoutEffect, useState } from "react";
|
|
10
10
|
import { ToggleButton } from "react-aria-components";
|
|
11
11
|
import { DebuggerControls } from "@/components/debugger/debugger-code";
|
|
12
12
|
import { CopyClipboardIcon } from "@/components/icons/copy-icon";
|
|
@@ -49,31 +49,21 @@ export const CONSOLE_CLEAR_DEBOUNCE_MS = 200;
|
|
|
49
49
|
*/
|
|
50
50
|
function useDebouncedConsoleOutputs<T>(outputs: T[]): T[] {
|
|
51
51
|
const [debouncedOutputs, setDebouncedOutputs] = useState(outputs);
|
|
52
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
52
|
|
|
54
|
-
// Non-empty outputs: apply immediately
|
|
53
|
+
// Non-empty outputs: apply immediately. Effect cleanup cancels pending clears.
|
|
55
54
|
if (outputs.length > 0 && debouncedOutputs !== outputs) {
|
|
56
|
-
if (timerRef.current !== null) {
|
|
57
|
-
clearTimeout(timerRef.current);
|
|
58
|
-
timerRef.current = null;
|
|
59
|
-
}
|
|
60
55
|
setDebouncedOutputs(outputs);
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
// Empty outputs: delay the clear so new outputs can arrive first
|
|
64
59
|
useEffect(() => {
|
|
65
|
-
if (outputs.length
|
|
66
|
-
|
|
67
|
-
timerRef.current = null;
|
|
68
|
-
setDebouncedOutputs([]);
|
|
69
|
-
}, CONSOLE_CLEAR_DEBOUNCE_MS);
|
|
60
|
+
if (outputs.length > 0) {
|
|
61
|
+
return;
|
|
70
62
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
};
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
setDebouncedOutputs([]);
|
|
65
|
+
}, CONSOLE_CLEAR_DEBOUNCE_MS);
|
|
66
|
+
return () => clearTimeout(timer);
|
|
77
67
|
}, [outputs]);
|
|
78
68
|
|
|
79
69
|
return debouncedOutputs;
|
|
@@ -117,7 +117,7 @@ export const SlideCellView = ({ cell }: { cell: RuntimeCell }) => {
|
|
|
117
117
|
/>
|
|
118
118
|
<LanguageToggles
|
|
119
119
|
code={cell.code}
|
|
120
|
-
|
|
120
|
+
editorViewRef={editorViewRef}
|
|
121
121
|
currentLanguageAdapter={languageAdapter}
|
|
122
122
|
onAfterToggle={afterToggleLanguage}
|
|
123
123
|
className="flex items-center gap-1"
|
package/src/core/static/files.ts
CHANGED
|
@@ -125,6 +125,8 @@ function withoutLeadingDot(path: string): string {
|
|
|
125
125
|
/**
|
|
126
126
|
* Resolve a URL to a blob URL if it's a virtual file, for use with dynamic import().
|
|
127
127
|
* Unlike fetch, import() can't be patched, so we need to convert data URLs to blob URLs.
|
|
128
|
+
* The caller owns a returned URL that differs from the input and must revoke it
|
|
129
|
+
* once no module consumers or their cleanup callbacks need it.
|
|
128
130
|
*
|
|
129
131
|
* @returns The original URL if not a virtual file, or a blob URL if it is
|
|
130
132
|
*/
|
|
@@ -37,12 +37,9 @@ export function useInputHistory({
|
|
|
37
37
|
const historyIndexRef = React.useRef<number>(-1);
|
|
38
38
|
// Store the current input when navigating history
|
|
39
39
|
const pendingInputRef = React.useRef<string>("");
|
|
40
|
-
// Keep a ref to history for use in callbacks without causing re-renders
|
|
41
|
-
const historyRef = React.useRef(history);
|
|
42
|
-
historyRef.current = history;
|
|
43
40
|
|
|
44
41
|
const navigateUp = React.useCallback(() => {
|
|
45
|
-
const currentHistory =
|
|
42
|
+
const currentHistory = history;
|
|
46
43
|
if (currentHistory.length === 0) {
|
|
47
44
|
return;
|
|
48
45
|
}
|
|
@@ -59,10 +56,10 @@ export function useInputHistory({
|
|
|
59
56
|
historyIndexRef.current = newIndex;
|
|
60
57
|
setValue(currentHistory[currentHistory.length - 1 - newIndex]);
|
|
61
58
|
}
|
|
62
|
-
}, [value, setValue]);
|
|
59
|
+
}, [history, value, setValue]);
|
|
63
60
|
|
|
64
61
|
const navigateDown = React.useCallback(() => {
|
|
65
|
-
const currentHistory =
|
|
62
|
+
const currentHistory = history;
|
|
66
63
|
if (currentHistory.length === 0) {
|
|
67
64
|
return;
|
|
68
65
|
}
|
|
@@ -79,7 +76,7 @@ export function useInputHistory({
|
|
|
79
76
|
historyIndexRef.current = -1;
|
|
80
77
|
setValue(pendingInputRef.current);
|
|
81
78
|
}
|
|
82
|
-
}, [setValue]);
|
|
79
|
+
}, [history, setValue]);
|
|
83
80
|
|
|
84
81
|
const addToHistory = React.useCallback((command: string) => {
|
|
85
82
|
setHistory((prev) => {
|
|
@@ -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 =
|
|
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
|
-
|
|
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() };
|