@marimo-team/islands 0.23.14-dev5 → 0.23.14-dev51
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-DXBx_nQg.js} +4 -4
- package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DXPRhRO2.js} +3248 -3044
- package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
- package/dist/{code-visibility-rxZi4Phe.js → code-visibility-BA5VF477.js} +1153 -946
- 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-WsOgyW_K.js} +1 -1
- package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-7wxMGXjG.js} +2084 -1889
- package/dist/{html-to-image-CGp_08St.js → html-to-image-DOqQBSQc.js} +2367 -2301
- package/dist/{input-CbEz_aj_.js → input-BSdZp5Ng.js} +1 -1
- package/dist/main.js +2707 -1186
- package/dist/{mermaid-CJW9vIyO.js → mermaid-D-HYBMEV.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-kGk2Jc2x.js} +1 -1
- package/dist/{reveal-component-sEb3Wd1x.js → reveal-component-BOL6pLBN.js} +600 -596
- package/dist/{spec-Bv-XlYiv.js → spec-CnTgI25l.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-D-l5s8nn.js → toDate-D1Z7ZXWh.js} +1 -1
- package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-BMc8itk2.js} +1 -1
- package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-ZwmDBRDY.js} +1 -1
- package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-CxffarYV.js} +1 -1
- package/dist/{useTheme-BrYvK-_A.js → useTheme-yGsGEk82.js} +26 -24
- package/dist/{vega-component-Pk6lyc_a.js → vega-component-BFJTyykA.js} +5 -5
- package/package.json +3 -3
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/app-config/user-config-form.tsx +26 -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/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/MarimoTracebackOutput.tsx +28 -2
- 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/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 +139 -0
- package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
- package/src/core/codemirror/cells/debugger-state.ts +100 -0
- package/src/core/codemirror/cells/extensions.ts +23 -4
- 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/feature-flag.tsx +2 -0
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/errors.ts +2 -1
- package/src/core/islands/bootstrap.ts +1 -0
- 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/adapter.ts +182 -0
- 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/useMarimoKernelConnection.tsx +17 -0
- 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/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 +53 -2
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +52 -2
- 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/utils/errors.ts +15 -0
- 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
|
@@ -5,54 +5,41 @@ import type React from "react";
|
|
|
5
5
|
import type { PropsWithChildren } from "react";
|
|
6
6
|
import { useEffect, useRef } from "react";
|
|
7
7
|
import { LargeSpinner } from "@/components/icons/large-spinner";
|
|
8
|
+
import { ProgressiveBoundary } from "@/components/lifecycle/ProgressiveBoundary";
|
|
8
9
|
import { toast } from "@/components/ui/use-toast";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { store } from "@/core/state/jotai";
|
|
10
|
+
import { canPaintAtom } from "@/core/lifecycle/render-policy";
|
|
11
|
+
import { wasmInitStateAtom } from "@/core/wasm/state";
|
|
12
12
|
import { useAsyncData } from "@/hooks/useAsyncData";
|
|
13
13
|
import { prettyError } from "@/utils/errors";
|
|
14
14
|
import { Logger } from "@/utils/Logger";
|
|
15
|
-
import { hasQueryParam } from "@/utils/urls";
|
|
16
|
-
import { KnownQueryParams } from "../constants";
|
|
17
|
-
import { type AppMode, getInitialAppMode } from "../mode";
|
|
18
15
|
import { PyodideBridge } from "./bridge";
|
|
19
|
-
import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
|
|
20
16
|
import { isWasm } from "./utils";
|
|
21
17
|
|
|
22
18
|
/**
|
|
23
|
-
*
|
|
19
|
+
* Paints the snapshot (or hydrated cells) immediately and lets Pyodide
|
|
20
|
+
* finish downloading in the background. UI elements aren't interactive
|
|
21
|
+
* until Pyodide is ready.
|
|
24
22
|
*/
|
|
25
23
|
export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
|
|
26
24
|
if (!isWasm()) {
|
|
27
25
|
return children;
|
|
28
26
|
}
|
|
29
|
-
|
|
30
27
|
return <PyodideLoaderInner>{children}</PyodideLoaderInner>;
|
|
31
28
|
};
|
|
32
29
|
|
|
33
30
|
const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
|
|
34
|
-
// Don't block render on Pyodide: a hydrated snapshot can paint immediately
|
|
35
|
-
// while Pyodide downloads in the background.
|
|
36
31
|
const { error } = useAsyncData(async () => {
|
|
37
32
|
await PyodideBridge.INSTANCE.initialized.promise;
|
|
38
33
|
return true;
|
|
39
34
|
}, []);
|
|
40
35
|
|
|
41
|
-
const
|
|
42
|
-
const hasOutput = useAtomValue(hasAnyOutputAtom);
|
|
43
|
-
const nothingToShow = shouldShowSpinner({
|
|
44
|
-
hasCells,
|
|
45
|
-
hasOutput,
|
|
46
|
-
mode: getInitialAppMode(),
|
|
47
|
-
codeHidden: isCodeHidden(),
|
|
48
|
-
});
|
|
36
|
+
const canPaint = useAtomValue(canPaintAtom);
|
|
49
37
|
|
|
38
|
+
// If the snapshot is already on screen, toast instead of throwing so we
|
|
39
|
+
// don't tear it down. With nothing painted, throw to surface the error UI.
|
|
50
40
|
const didToastErrorRef = useRef(false);
|
|
51
41
|
useEffect(() => {
|
|
52
|
-
|
|
53
|
-
// snapshot stays readable. The ref ensures we only toast once even if
|
|
54
|
-
// nothingToShow toggles later.
|
|
55
|
-
if (error && !nothingToShow && !didToastErrorRef.current) {
|
|
42
|
+
if (error && canPaint && !didToastErrorRef.current) {
|
|
56
43
|
didToastErrorRef.current = true;
|
|
57
44
|
Logger.error("Pyodide failed to initialize", error);
|
|
58
45
|
toast({
|
|
@@ -61,50 +48,21 @@ const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
|
|
|
61
48
|
variant: "danger",
|
|
62
49
|
});
|
|
63
50
|
}
|
|
64
|
-
}, [error,
|
|
51
|
+
}, [error, canPaint]);
|
|
65
52
|
|
|
66
|
-
if (error &&
|
|
53
|
+
if (error && !canPaint) {
|
|
67
54
|
throw error;
|
|
68
55
|
}
|
|
69
56
|
|
|
70
|
-
if (nothingToShow) {
|
|
71
|
-
return <WasmSpinner />;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return children;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
function isCodeHidden() {
|
|
78
|
-
// Code is hidden if ANY are true:
|
|
79
|
-
// - the query param is set to false
|
|
80
|
-
// - the view.showAppCode is false
|
|
81
57
|
return (
|
|
82
|
-
|
|
83
|
-
|
|
58
|
+
<ProgressiveBoundary requires={canPaintAtom} fallback={<WasmSpinner />}>
|
|
59
|
+
{children}
|
|
60
|
+
</ProgressiveBoundary>
|
|
84
61
|
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Pure predicate: should the WASM loader render a spinner instead of its
|
|
89
|
-
* children? We block render only when nothing user-visible would appear:
|
|
90
|
-
* - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
|
|
91
|
-
* - we are in headless run mode (code hidden) with no outputs to display.
|
|
92
|
-
*/
|
|
93
|
-
export function shouldShowSpinner(input: {
|
|
94
|
-
hasCells: boolean;
|
|
95
|
-
hasOutput: boolean;
|
|
96
|
-
mode: AppMode;
|
|
97
|
-
codeHidden: boolean;
|
|
98
|
-
}): boolean {
|
|
99
|
-
const { hasCells, hasOutput, mode, codeHidden } = input;
|
|
100
|
-
if (!hasCells) {
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
return !hasOutput && mode === "read" && codeHidden;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
|
|
107
|
-
const wasmInitialization = useAtomValue(wasmInitializationAtom);
|
|
62
|
+
};
|
|
108
63
|
|
|
109
|
-
|
|
64
|
+
const WasmSpinner: React.FC = () => {
|
|
65
|
+
const state = useAtomValue(wasmInitStateAtom);
|
|
66
|
+
const title = state.kind === "loading" ? state.message : "Loading…";
|
|
67
|
+
return <LargeSpinner title={title} />;
|
|
110
68
|
};
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
|
|
|
38
38
|
import { PyodideRouter } from "./router";
|
|
39
39
|
import { getWorkerRPC } from "./rpc";
|
|
40
40
|
import { createShareableLink } from "./share";
|
|
41
|
-
import {
|
|
41
|
+
import { wasmInitStateAtom } from "./state";
|
|
42
42
|
import { fallbackFileStore, notebookFileStore } from "./store";
|
|
43
43
|
import { isWasm } from "./utils";
|
|
44
44
|
import type { SaveWorkerSchema } from "./worker/save-worker";
|
|
@@ -120,11 +120,16 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
120
120
|
// By initializing after, we get hits on cached network requests
|
|
121
121
|
this.saveRpc = this.getSaveWorker();
|
|
122
122
|
this.setInterruptBuffer();
|
|
123
|
-
store.set(
|
|
123
|
+
store.set(wasmInitStateAtom, { kind: "ready" });
|
|
124
124
|
this.initialized.resolve();
|
|
125
125
|
});
|
|
126
126
|
this.rpc.addMessageListener("initializingMessage", ({ message }) => {
|
|
127
|
-
|
|
127
|
+
// Only bump the progress label while still loading — if we've already
|
|
128
|
+
// reached "ready" or "error", a late message shouldn't roll us back.
|
|
129
|
+
const current = store.get(wasmInitStateAtom);
|
|
130
|
+
if (current.kind === "loading") {
|
|
131
|
+
store.set(wasmInitStateAtom, { kind: "loading", message });
|
|
132
|
+
}
|
|
128
133
|
});
|
|
129
134
|
this.rpc.addMessageListener("initializedError", ({ error }) => {
|
|
130
135
|
// If already initialized, surface as a toast and leave the deferred /
|
|
@@ -138,7 +143,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
138
143
|
});
|
|
139
144
|
return;
|
|
140
145
|
}
|
|
141
|
-
store.set(
|
|
146
|
+
store.set(wasmInitStateAtom, { kind: "error", message: error });
|
|
142
147
|
this.initialized.reject(new Error(error));
|
|
143
148
|
});
|
|
144
149
|
this.rpc.addMessageListener("kernelMessage", ({ message }) => {
|
|
@@ -252,6 +257,10 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
252
257
|
throwNotImplemented();
|
|
253
258
|
};
|
|
254
259
|
|
|
260
|
+
sendSetBreakpoints: EditRequests["sendSetBreakpoints"] = async () => {
|
|
261
|
+
throwNotImplemented();
|
|
262
|
+
};
|
|
263
|
+
|
|
255
264
|
sendRun: EditRequests["sendRun"] = async (request) => {
|
|
256
265
|
await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
|
|
257
266
|
|
package/src/core/wasm/state.ts
CHANGED
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { atom } from "jotai";
|
|
3
|
-
import { notebookAtom } from "../cells/cells";
|
|
4
|
-
import { isOutputEmpty } from "../cells/outputs";
|
|
5
3
|
|
|
6
|
-
|
|
4
|
+
/** Tagged so the progress label and error message can't drift apart. */
|
|
5
|
+
export type WasmInitState =
|
|
6
|
+
| { kind: "loading"; message: string }
|
|
7
|
+
| { kind: "ready" }
|
|
8
|
+
| { kind: "error"; message: string };
|
|
7
9
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export const hasAnyOutputAtom = atom<boolean>((get) => {
|
|
12
|
-
const notebook = get(notebookAtom);
|
|
13
|
-
const runtimeStates = Object.values(notebook.cellRuntime);
|
|
14
|
-
// First check if there is any output
|
|
15
|
-
const hasOutput = runtimeStates.some(
|
|
16
|
-
(runtime) => !isOutputEmpty(runtime.output),
|
|
17
|
-
);
|
|
18
|
-
if (hasOutput) {
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
// If there is no output, check if they have all run to completion
|
|
22
|
-
// in case this notebook doesn't contain outputs
|
|
23
|
-
return runtimeStates.every((runtime) => runtime.status === "idle");
|
|
10
|
+
export const wasmInitStateAtom = atom<WasmInitState>({
|
|
11
|
+
kind: "loading",
|
|
12
|
+
message: "Initializing...",
|
|
24
13
|
});
|
|
@@ -32,6 +32,10 @@ import { cacheInfoAtom } from "../cache/requests";
|
|
|
32
32
|
import { SCRATCH_CELL_ID } from "../cells/ids";
|
|
33
33
|
import { useRunsActions } from "../cells/runs";
|
|
34
34
|
import { focusAndScrollCellOutputIntoView } from "../cells/scrollCellIntoView";
|
|
35
|
+
import {
|
|
36
|
+
debuggerCurrentLineAtom,
|
|
37
|
+
resyncBreakpoints,
|
|
38
|
+
} from "../codemirror/cells/debugger-state";
|
|
35
39
|
import type { CellData } from "../cells/types";
|
|
36
40
|
import { capabilitiesAtom } from "../config/capabilities";
|
|
37
41
|
import { useSetAppConfig } from "../config/config";
|
|
@@ -196,6 +200,7 @@ export function useMarimoKernelConnection(opts: {
|
|
|
196
200
|
const runtimeManager = useRuntimeManager();
|
|
197
201
|
const setCacheInfo = useSetAtom(cacheInfoAtom);
|
|
198
202
|
const setKernelStartupError = useSetAtom(kernelStartupErrorAtom);
|
|
203
|
+
const setDebuggerCurrentLine = useSetAtom(debuggerCurrentLineAtom);
|
|
199
204
|
const {
|
|
200
205
|
setNamespaces: setStorageNamespaces,
|
|
201
206
|
filterFromVariables: filterStorageFromVariables,
|
|
@@ -221,6 +226,11 @@ export function useMarimoKernelConnection(opts: {
|
|
|
221
226
|
existingCells,
|
|
222
227
|
});
|
|
223
228
|
setKioskMode(msg.data.kiosk);
|
|
229
|
+
// A freshly started kernel has no breakpoints of its own; re-push
|
|
230
|
+
// the client's set so they still apply, and clear the stale
|
|
231
|
+
// highlighted line from the previous kernel instance.
|
|
232
|
+
setDebuggerCurrentLine(null);
|
|
233
|
+
resyncBreakpoints();
|
|
224
234
|
return;
|
|
225
235
|
}
|
|
226
236
|
|
|
@@ -392,6 +402,13 @@ export function useMarimoKernelConnection(opts: {
|
|
|
392
402
|
case "focus-cell":
|
|
393
403
|
focusAndScrollCellOutputIntoView(msg.data.cell_id);
|
|
394
404
|
return;
|
|
405
|
+
case "active-line":
|
|
406
|
+
setDebuggerCurrentLine(
|
|
407
|
+
msg.data.line == null
|
|
408
|
+
? null
|
|
409
|
+
: { cellId: msg.data.cell_id, line: msg.data.line },
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
395
412
|
case "notebook-document-transaction":
|
|
396
413
|
handleDocumentTransaction(msg.data.transaction);
|
|
397
414
|
return;
|
|
@@ -138,3 +138,17 @@
|
|
|
138
138
|
display: inline-block;
|
|
139
139
|
padding: 2px;
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/* Rename symbol popup (F2). The upstream popup hardcodes white inline styles;
|
|
143
|
+
override with theme tokens. */
|
|
144
|
+
.cm-rename-popup {
|
|
145
|
+
background: var(--popover) !important;
|
|
146
|
+
border-color: var(--border) !important;
|
|
147
|
+
color: var(--popover-foreground) !important;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.cm-rename-popup input {
|
|
151
|
+
background: var(--background) !important;
|
|
152
|
+
border-color: var(--border) !important;
|
|
153
|
+
color: var(--foreground) !important;
|
|
154
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useDelayElapsed } from "../useDelayElapsed";
|
|
6
|
+
|
|
7
|
+
describe("useDelayElapsed", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("resolves true immediately when delay <= 0", () => {
|
|
17
|
+
const { result } = renderHook(() => useDelayElapsed(0));
|
|
18
|
+
expect(result.current).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("stays false until the delay elapses, then flips true", () => {
|
|
22
|
+
const { result } = renderHook(() => useDelayElapsed(2000));
|
|
23
|
+
expect(result.current).toBe(false);
|
|
24
|
+
|
|
25
|
+
act(() => vi.advanceTimersByTime(1999));
|
|
26
|
+
expect(result.current).toBe(false);
|
|
27
|
+
|
|
28
|
+
act(() => vi.advanceTimersByTime(1));
|
|
29
|
+
expect(result.current).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("restarts the timer when the delay changes", () => {
|
|
33
|
+
const { result, rerender } = renderHook(
|
|
34
|
+
({ delay }) => useDelayElapsed(delay),
|
|
35
|
+
{ initialProps: { delay: 1000 } },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
39
|
+
expect(result.current).toBe(true);
|
|
40
|
+
|
|
41
|
+
rerender({ delay: 500 });
|
|
42
|
+
expect(result.current).toBe(false);
|
|
43
|
+
|
|
44
|
+
act(() => vi.advanceTimersByTime(500));
|
|
45
|
+
expect(result.current).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("clears the timer on unmount", () => {
|
|
49
|
+
const { unmount } = renderHook(() => useDelayElapsed(1000));
|
|
50
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
51
|
+
unmount();
|
|
52
|
+
// The pending timeout must be cleared, not left dangling.
|
|
53
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns `false` until `delayMs` has elapsed since mount (or since `delayMs`
|
|
7
|
+
* last changed), then `true`. Resolves to `true` immediately when
|
|
8
|
+
* `delayMs <= 0`. Useful for suppressing spinner/fallback flashes.
|
|
9
|
+
*/
|
|
10
|
+
export function useDelayElapsed(delayMs: number): boolean {
|
|
11
|
+
const [elapsed, setElapsed] = useState(delayMs <= 0);
|
|
12
|
+
|
|
13
|
+
// Layout effect (not a plain effect) so that when `delayMs` flips from `<= 0`
|
|
14
|
+
// to `> 0` — e.g. a gate re-closing — we reset `elapsed` to `false` before
|
|
15
|
+
// the browser paints, rather than briefly flashing the fallback for a frame.
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
if (delayMs <= 0) {
|
|
18
|
+
setElapsed(true);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
setElapsed(false);
|
|
22
|
+
const timeout = setTimeout(() => setElapsed(true), delayMs);
|
|
23
|
+
return () => clearTimeout(timeout);
|
|
24
|
+
}, [delayMs]);
|
|
25
|
+
|
|
26
|
+
return elapsed;
|
|
27
|
+
}
|
|
@@ -199,6 +199,37 @@ const keyImagesBySrc: TransformFn = (
|
|
|
199
199
|
return cloneElement(reactNode, { key: `${src}-${index}` });
|
|
200
200
|
};
|
|
201
201
|
|
|
202
|
+
// Decorator: applies a checked-state-based key to task-list checkbox
|
|
203
|
+
// <input> elements (e.g. GFM `- [ ]` / `- [x]` rendered by
|
|
204
|
+
// pymdownx.tasklist) so they remount when their checked-ness changes.
|
|
205
|
+
//
|
|
206
|
+
// pymdownx.tasklist omits the `checked` HTML attribute entirely for
|
|
207
|
+
// unchecked items (rather than emitting checked="false"). When markdown
|
|
208
|
+
// edits shift which item lands at a given tree position (e.g. deleting
|
|
209
|
+
// completed items above unchecked ones), reusing the same DOM <input>
|
|
210
|
+
// across a checked -> unchecked item is a controlled -> uncontrolled prop
|
|
211
|
+
// transition for React: React stops managing the DOM `checked` property in
|
|
212
|
+
// that transition instead of resetting it, leaving the previous item's
|
|
213
|
+
// checked state visually stuck until a full remount. Baking checked-ness
|
|
214
|
+
// into the key forces a fresh node instead of reusing the stale one.
|
|
215
|
+
const keyTaskListCheckboxesByChecked: TransformFn = (
|
|
216
|
+
reactNode: ReactNode,
|
|
217
|
+
domNode: DOMNode,
|
|
218
|
+
index: number,
|
|
219
|
+
): JSX.Element | undefined => {
|
|
220
|
+
if (!(domNode instanceof Element) || domNode.name !== "input") {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (domNode.attribs?.type !== "checkbox") {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (!isValidElement(reactNode)) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const checked = "checked" in domNode.attribs;
|
|
230
|
+
return cloneElement(reactNode, { key: `checkbox-${index}-${checked}` });
|
|
231
|
+
};
|
|
232
|
+
|
|
202
233
|
// Wrap elements with data-marimo-doc attribute in a DocHoverTarget
|
|
203
234
|
const wrapDocHoverTargets: TransformFn = (
|
|
204
235
|
reactNode: ReactNode,
|
|
@@ -326,7 +357,10 @@ function parseHtml({
|
|
|
326
357
|
// and may further wrap/clone it. Used for cross-cutting concerns that
|
|
327
358
|
// should apply regardless of which (if any) match-and-replace transform
|
|
328
359
|
// ran above.
|
|
329
|
-
const decoratorFunctions: TransformFn[] = [
|
|
360
|
+
const decoratorFunctions: TransformFn[] = [
|
|
361
|
+
keyImagesBySrc,
|
|
362
|
+
keyTaskListCheckboxesByChecked,
|
|
363
|
+
];
|
|
330
364
|
|
|
331
365
|
return parse(html, {
|
|
332
366
|
replace: (domNode: DOMNode, index: number) => {
|
|
@@ -114,6 +114,50 @@ describe("parseHtml", () => {
|
|
|
114
114
|
expect(result.key).toBe("https://cdn.example.com/b.png-0");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("checked task-list checkbox is keyed by index and checked state", () => {
|
|
118
|
+
const html = '<li><input type="checkbox" checked disabled> item</li>';
|
|
119
|
+
const result = parseHtml({ html }) as React.ReactElement<{
|
|
120
|
+
children: React.ReactElement[];
|
|
121
|
+
}>;
|
|
122
|
+
const checkbox = result.props.children[0];
|
|
123
|
+
expect(checkbox.key).toBe("checkbox-0-true");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("unchecked task-list checkbox is keyed by index and checked state", () => {
|
|
127
|
+
const html = '<li><input type="checkbox" disabled> item</li>';
|
|
128
|
+
const result = parseHtml({ html }) as React.ReactElement<{
|
|
129
|
+
children: React.ReactElement[];
|
|
130
|
+
}>;
|
|
131
|
+
const checkbox = result.props.children[0];
|
|
132
|
+
expect(checkbox.key).toBe("checkbox-0-false");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("task list: item reused at same position gets a new key when checked state flips", () => {
|
|
136
|
+
// Simulates editing markdown so an unchecked item now sits where a
|
|
137
|
+
// checked item used to be (e.g. an earlier checked item was deleted).
|
|
138
|
+
const before = parseHtml({
|
|
139
|
+
html: '<ul><li><input type="checkbox" checked disabled> a</li></ul>',
|
|
140
|
+
}) as React.ReactElement<{ children: React.ReactElement }>;
|
|
141
|
+
const after = parseHtml({
|
|
142
|
+
html: '<ul><li><input type="checkbox" disabled> b</li></ul>',
|
|
143
|
+
}) as React.ReactElement<{ children: React.ReactElement }>;
|
|
144
|
+
const beforeLi = before.props.children as React.ReactElement<{
|
|
145
|
+
children: React.ReactElement[];
|
|
146
|
+
}>;
|
|
147
|
+
const afterLi = after.props.children as React.ReactElement<{
|
|
148
|
+
children: React.ReactElement[];
|
|
149
|
+
}>;
|
|
150
|
+
const beforeKey = beforeLi.props.children[0].key;
|
|
151
|
+
const afterKey = afterLi.props.children[0].key;
|
|
152
|
+
expect(beforeKey).not.toBe(afterKey);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("non-checkbox input is left alone", () => {
|
|
156
|
+
const html = '<input type="text" value="hi">';
|
|
157
|
+
const result = parseHtml({ html }) as React.ReactElement;
|
|
158
|
+
expect(result.key).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
117
161
|
test("codehilite with copy button", () => {
|
|
118
162
|
const html =
|
|
119
163
|
'<div class="codehilite"><pre><code>console.log("Hello");</code></pre></div>';
|
|
@@ -140,7 +140,8 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
140
140
|
return null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
const widget = resolveAnyWidget(jsModule, jsUrl);
|
|
144
|
+
if (!widget) {
|
|
144
145
|
return (
|
|
145
146
|
<ErrorBanner error={getInvalidAnyWidgetModuleError(jsModule, jsUrl)} />
|
|
146
147
|
);
|
|
@@ -150,7 +151,7 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
|
150
151
|
<LoadedSlot
|
|
151
152
|
// Force remount when the widget module or model changes (cell re-run).
|
|
152
153
|
key={`${jsHash}:${modelId}`}
|
|
153
|
-
widget={
|
|
154
|
+
widget={widget}
|
|
154
155
|
modelId={modelId}
|
|
155
156
|
host={host}
|
|
156
157
|
/>
|
|
@@ -200,6 +201,55 @@ function isAnyWidgetModule(mod: any): mod is { default: AnyWidget } {
|
|
|
200
201
|
);
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
const warnedLegacyNamedExportUrls = new Set<string>();
|
|
205
|
+
// Cache the synthesized widget per module namespace so its identity stays
|
|
206
|
+
// stable across re-renders (like a default export), avoiding needless
|
|
207
|
+
// WidgetBinding re-initialization.
|
|
208
|
+
const legacyWidgetCache = new WeakMap<object, AnyWidget>();
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the AnyWidget from a loaded module: prefer the AFM-spec default
|
|
212
|
+
* export, otherwise synthesize one from legacy named `render`/`initialize`
|
|
213
|
+
* exports. Returns null if neither is present.
|
|
214
|
+
*/
|
|
215
|
+
function resolveAnyWidget(mod: any, jsUrl: string): AnyWidget | null {
|
|
216
|
+
if (isAnyWidgetModule(mod)) {
|
|
217
|
+
return mod.default;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Only fall back to legacy (pre-AFM) named exports when there is no default
|
|
221
|
+
// export at all; a present-but-invalid default should surface an error
|
|
222
|
+
// rather than be masked.
|
|
223
|
+
if (mod?.default != null) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const hasNamedRender = typeof mod?.render === "function";
|
|
228
|
+
const hasNamedInitialize = typeof mod?.initialize === "function";
|
|
229
|
+
if (!hasNamedRender && !hasNamedInitialize) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cached = legacyWidgetCache.get(mod);
|
|
234
|
+
if (cached) {
|
|
235
|
+
return cached;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!warnedLegacyNamedExportUrls.has(jsUrl)) {
|
|
239
|
+
warnedLegacyNamedExportUrls.add(jsUrl);
|
|
240
|
+
Logger.warn(
|
|
241
|
+
`Anywidget module at ${jsUrl} uses deprecated top-level named ` +
|
|
242
|
+
"exports (`render`/`initialize`). Per the AFM spec, use a default " +
|
|
243
|
+
"export instead: `export default { render }`. " +
|
|
244
|
+
"See https://anywidget.dev/en/afm/",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const widget: AnyWidget = { render: mod.render, initialize: mod.initialize };
|
|
249
|
+
legacyWidgetCache.set(mod, widget);
|
|
250
|
+
return widget;
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
function getInvalidAnyWidgetModuleError(mod: unknown, jsUrl: string): Error {
|
|
204
254
|
const afmDocs = "https://anywidget.dev/en/afm/";
|
|
205
255
|
const hasNamedRender = isRecord(mod) && hasFunctionProperty(mod, "render");
|
|
@@ -296,5 +346,6 @@ export const visibleForTesting = {
|
|
|
296
346
|
LoadedSlot,
|
|
297
347
|
runAnyWidgetModule,
|
|
298
348
|
isAnyWidgetModule,
|
|
349
|
+
resolveAnyWidget,
|
|
299
350
|
getInvalidAnyWidgetModuleError,
|
|
300
351
|
};
|
|
@@ -9,8 +9,12 @@ import { MODEL_MANAGER, Model } from "../model";
|
|
|
9
9
|
import type { WidgetModelId } from "../types";
|
|
10
10
|
import { BINDING_MANAGER } from "../widget-binding";
|
|
11
11
|
|
|
12
|
-
const {
|
|
13
|
-
|
|
12
|
+
const {
|
|
13
|
+
LoadedSlot,
|
|
14
|
+
isAnyWidgetModule,
|
|
15
|
+
resolveAnyWidget,
|
|
16
|
+
getInvalidAnyWidgetModuleError,
|
|
17
|
+
} = visibleForTesting;
|
|
14
18
|
|
|
15
19
|
// Helper to create typed model IDs for tests
|
|
16
20
|
const asModelId = (id: string): WidgetModelId => id as WidgetModelId;
|
|
@@ -170,6 +174,52 @@ describe("isAnyWidgetModule", () => {
|
|
|
170
174
|
});
|
|
171
175
|
});
|
|
172
176
|
|
|
177
|
+
describe("resolveAnyWidget", () => {
|
|
178
|
+
const jsUrl = "./@file/widget.js";
|
|
179
|
+
|
|
180
|
+
it("should return the default export when present", () => {
|
|
181
|
+
const widget = { render: () => undefined };
|
|
182
|
+
expect(resolveAnyWidget({ default: widget }, jsUrl)).toBe(widget);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should return a default factory function", () => {
|
|
186
|
+
const factory = async () => ({ render: () => {} });
|
|
187
|
+
expect(resolveAnyWidget({ default: factory }, jsUrl)).toBe(factory);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should synthesize a widget from a legacy named render export", () => {
|
|
191
|
+
const render = vi.fn();
|
|
192
|
+
const resolved = resolveAnyWidget({ render }, jsUrl);
|
|
193
|
+
expect(resolved).not.toBeNull();
|
|
194
|
+
expect(resolved).toMatchObject({ render });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should synthesize a widget from a legacy named initialize export", () => {
|
|
198
|
+
const initialize = vi.fn();
|
|
199
|
+
const resolved = resolveAnyWidget({ initialize }, jsUrl);
|
|
200
|
+
expect(resolved).not.toBeNull();
|
|
201
|
+
expect(resolved).toMatchObject({ initialize });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should return null for a module with no valid exports", () => {
|
|
205
|
+
expect(resolveAnyWidget({}, jsUrl)).toBeNull();
|
|
206
|
+
expect(resolveAnyWidget({ default: {} }, jsUrl)).toBeNull();
|
|
207
|
+
expect(resolveAnyWidget({ render: "not a function" }, jsUrl)).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should return a stable widget identity across calls for one module", () => {
|
|
211
|
+
const mod = { render: vi.fn() };
|
|
212
|
+
expect(resolveAnyWidget(mod, jsUrl)).toBe(resolveAnyWidget(mod, jsUrl));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("should not fall back to named exports when a default export is present", () => {
|
|
216
|
+
// A present-but-invalid default should surface an error, not be masked.
|
|
217
|
+
expect(
|
|
218
|
+
resolveAnyWidget({ default: {}, render: vi.fn() }, jsUrl),
|
|
219
|
+
).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
173
223
|
describe("getInvalidAnyWidgetModuleError", () => {
|
|
174
224
|
const jsUrl = "./@file/widget.js";
|
|
175
225
|
|