@marimo-team/islands 0.23.14-dev3 → 0.23.14-dev31

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 (97) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-DXBx_nQg.js} +4 -4
  2. package/dist/{chat-ui-CsPewo4h.js → chat-ui-CO8WuXGb.js} +3243 -3039
  3. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  4. package/dist/{code-visibility-BbnxGblD.js → code-visibility-0uqOIXQg.js} +1122 -924
  5. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  6. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  7. package/dist/{formats-d6MhLuQ9.js → formats-WsOgyW_K.js} +1 -1
  8. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-7wxMGXjG.js} +2084 -1889
  9. package/dist/{html-to-image-DXwLcQ6l.js → html-to-image-DLSOS-bE.js} +2351 -2282
  10. package/dist/{input-CbEz_aj_.js → input-BSdZp5Ng.js} +1 -1
  11. package/dist/main.js +1231 -1133
  12. package/dist/{mermaid-CJW9vIyO.js → mermaid-D-HYBMEV.js} +2 -2
  13. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  14. package/dist/{process-output-C6_e1pT_.js → process-output-B9ysqk1y.js} +1 -1
  15. package/dist/{reveal-component-B2onAQFS.js → reveal-component-DxTNjwZw.js} +600 -596
  16. package/dist/{spec-Bv-XlYiv.js → spec-CnTgI25l.js} +1 -1
  17. package/dist/style.css +1 -1
  18. package/dist/{toDate-D-l5s8nn.js → toDate-D1Z7ZXWh.js} +1 -1
  19. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-BMc8itk2.js} +1 -1
  20. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-ZwmDBRDY.js} +1 -1
  21. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-CxffarYV.js} +1 -1
  22. package/dist/{useTheme-BrYvK-_A.js → useTheme-yGsGEk82.js} +26 -24
  23. package/dist/{vega-component-Pk6lyc_a.js → vega-component-BFJTyykA.js} +5 -5
  24. package/package.json +3 -3
  25. package/src/components/chat/acp/agent-panel.tsx +35 -1
  26. package/src/components/chat/chat-panel.tsx +68 -29
  27. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  28. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  29. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  30. package/src/components/data-table/charts/charts.tsx +23 -1
  31. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  32. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  33. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  34. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -2
  35. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  36. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  37. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  38. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  39. package/src/components/editor/code/readonly-python-code.tsx +16 -2
  40. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  41. package/src/components/editor/errors/auto-fix.tsx +88 -34
  42. package/src/components/editor/errors/fix-mode.ts +1 -1
  43. package/src/components/editor/output/MarimoTracebackOutput.tsx +10 -1
  44. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  45. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  46. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +6 -2
  47. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  48. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  49. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  50. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  51. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  52. package/src/components/slides/minimap.tsx +7 -1
  53. package/src/components/slides/reveal-component.tsx +3 -0
  54. package/src/components/slides/slide-cell-view.tsx +10 -6
  55. package/src/components/slides/slide.tsx +16 -13
  56. package/src/core/ai/config.ts +2 -2
  57. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  58. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  59. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  60. package/src/core/ai/context/providers/datasource.ts +27 -2
  61. package/src/core/ai/context/providers/error.ts +226 -36
  62. package/src/core/ai/context/registry.ts +77 -43
  63. package/src/core/ai/state.ts +11 -0
  64. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  65. package/src/core/cells/readonly-code-display.ts +35 -0
  66. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  67. package/src/core/codemirror/ai/resources.ts +15 -10
  68. package/src/core/codemirror/cells/extensions.ts +1 -4
  69. package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +94 -0
  70. package/src/core/codemirror/completion/completer.ts +12 -1
  71. package/src/core/codemirror/completion/signature-hint.ts +68 -0
  72. package/src/core/codemirror/language/languages/python.ts +10 -4
  73. package/src/core/codemirror/utils.ts +15 -0
  74. package/src/core/edit-app.tsx +8 -5
  75. package/src/core/errors/errors.ts +2 -1
  76. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  77. package/src/core/lifecycle/render-policy.ts +125 -0
  78. package/src/core/mime.ts +11 -4
  79. package/src/core/run-app.tsx +27 -23
  80. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  81. package/src/core/runtime/adapter.ts +182 -0
  82. package/src/core/wasm/PyodideLoader.tsx +20 -62
  83. package/src/core/wasm/bridge.ts +9 -4
  84. package/src/core/wasm/state.ts +8 -19
  85. package/src/css/app/codemirror.css +14 -0
  86. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  87. package/src/hooks/useDelayElapsed.ts +27 -0
  88. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  89. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +53 -2
  90. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +52 -2
  91. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  92. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  93. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  94. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  95. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  96. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  97. 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 { hasCellsAtom } from "@/core/cells/cells";
10
- import { showCodeInRunModeAtom } from "@/core/meta/state";
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
- * HOC to load Pyodide before rendering children, if necessary.
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 hasCells = useAtomValue(hasCellsAtom);
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
- // With snapshot content on-screen, toast instead of throwing so the
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, nothingToShow]);
51
+ }, [error, canPaint]);
65
52
 
66
- if (error && nothingToShow) {
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
- hasQueryParam(KnownQueryParams.showCode, "false") ||
83
- !store.get(showCodeInRunModeAtom)
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
- return <LargeSpinner title={wasmInitialization} />;
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
  };
@@ -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 { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
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(wasmInitStatusAtom, "ready");
123
+ store.set(wasmInitStateAtom, { kind: "ready" });
124
124
  this.initialized.resolve();
125
125
  });
126
126
  this.rpc.addMessageListener("initializingMessage", ({ message }) => {
127
- store.set(wasmInitializationAtom, message);
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(wasmInitStatusAtom, "error");
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 }) => {
@@ -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
- export const wasmInitializationAtom = atom<string>("Initializing...");
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 type WasmInitStatus = "loading" | "ready" | "error";
9
- export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");
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
  });
@@ -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
+ }
@@ -805,6 +805,7 @@ export const LoadingDataTableComponent = memo(
805
805
  dataTable={dataTable}
806
806
  getDataUrl={props.get_data_url}
807
807
  fieldTypes={props.fieldTypes}
808
+ rowHeaders={props.rowHeaders}
808
809
  cellId={cellId}
809
810
  />
810
811
  ) : (
@@ -140,7 +140,8 @@ const AnyWidgetSlot = (props: IPluginProps<ModelIdRef, Data>) => {
140
140
  return null;
141
141
  }
142
142
 
143
- if (!isAnyWidgetModule(jsModule)) {
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={jsModule.default}
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 { LoadedSlot, isAnyWidgetModule, getInvalidAnyWidgetModuleError } =
13
- visibleForTesting;
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
 
@@ -0,0 +1,187 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import React from "react";
4
+ import { act, render, screen, waitFor } from "@testing-library/react";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TooltipProvider } from "@/components/ui/tooltip";
7
+ import { GlideDataEditor } from "../glide-data-editor";
8
+
9
+ const capturedPortalRef = vi.hoisted(() => ({
10
+ ref: undefined as React.RefObject<HTMLElement> | undefined,
11
+ }));
12
+
13
+ vi.mock("@glideapps/glide-data-grid", async () => {
14
+ const React = await import("react");
15
+ return {
16
+ default: React.forwardRef(function MockDataEditor(
17
+ props: { portalElementRef?: React.RefObject<HTMLElement> },
18
+ _ref: React.Ref<HTMLDivElement>,
19
+ ) {
20
+ capturedPortalRef.ref = props.portalElementRef;
21
+ return <div data-testid="mock-data-editor" />;
22
+ }),
23
+ CompactSelection: {
24
+ empty: () => ({}),
25
+ },
26
+ GridCellKind: {
27
+ Text: "text",
28
+ Number: "number",
29
+ Boolean: "boolean",
30
+ },
31
+ GridColumnIcon: {
32
+ ProtectedColumnOverlay: "protected",
33
+ },
34
+ };
35
+ });
36
+
37
+ vi.mock("@/theme/useTheme", () => ({
38
+ useTheme: () => ({ theme: "light" }),
39
+ }));
40
+
41
+ const editorProps = {
42
+ data: [{ name: "alice" }],
43
+ setData: vi.fn(),
44
+ columnFields: new Map([["name", "string"]]) as Map<string, "string">,
45
+ setColumnFields: vi.fn(),
46
+ editableColumns: "all" as const,
47
+ edits: [],
48
+ onAddEdits: vi.fn(),
49
+ onAddRows: vi.fn(),
50
+ onDeleteRows: vi.fn(),
51
+ onRenameColumn: vi.fn(),
52
+ onDeleteColumn: vi.fn(),
53
+ onAddColumn: vi.fn(),
54
+ };
55
+
56
+ describe("GlideDataEditor portal", () => {
57
+ let fullscreenElement: Element | null;
58
+
59
+ beforeEach(() => {
60
+ fullscreenElement = null;
61
+ Object.defineProperty(document, "fullscreenElement", {
62
+ get: () => fullscreenElement,
63
+ configurable: true,
64
+ });
65
+ document.body
66
+ .querySelectorAll("[data-testid='glide-data-editor-portal']")
67
+ .forEach((node) => {
68
+ node.remove();
69
+ });
70
+ });
71
+
72
+ it("renders a body-level portal and passes it to DataEditor", async () => {
73
+ const { container } = render(
74
+ <TooltipProvider>
75
+ <GlideDataEditor {...editorProps} />
76
+ </TooltipProvider>,
77
+ );
78
+
79
+ const portal = screen.getByTestId("glide-data-editor-portal");
80
+ expect(portal.parentElement).toBe(document.body);
81
+ expect(container.contains(portal)).toBe(false);
82
+ expect(capturedPortalRef.ref).toBeDefined();
83
+ await waitFor(() => {
84
+ expect(capturedPortalRef.ref?.current).toBe(portal);
85
+ });
86
+ });
87
+
88
+ it("mounts into the fullscreen element when fullscreen is already active", () => {
89
+ const fullscreenContainer = document.createElement("div");
90
+ document.body.appendChild(fullscreenContainer);
91
+ fullscreenElement = fullscreenContainer;
92
+
93
+ render(
94
+ <TooltipProvider>
95
+ <GlideDataEditor {...editorProps} />
96
+ </TooltipProvider>,
97
+ );
98
+
99
+ const portal = fullscreenContainer.querySelector(
100
+ "[data-testid='glide-data-editor-portal']",
101
+ );
102
+ expect(portal).not.toBeNull();
103
+ expect(portal?.parentElement).toBe(fullscreenContainer);
104
+
105
+ fullscreenContainer.remove();
106
+ });
107
+
108
+ it("moves the portal into the fullscreen element while fullscreen is active", async () => {
109
+ render(
110
+ <TooltipProvider>
111
+ <GlideDataEditor {...editorProps} />
112
+ </TooltipProvider>,
113
+ );
114
+
115
+ expect(
116
+ document.body.querySelector("[data-testid='glide-data-editor-portal']"),
117
+ ).not.toBeNull();
118
+
119
+ const fullscreenContainer = document.createElement("div");
120
+ document.body.appendChild(fullscreenContainer);
121
+
122
+ act(() => {
123
+ fullscreenElement = fullscreenContainer;
124
+ document.dispatchEvent(new Event("fullscreenchange"));
125
+ });
126
+
127
+ await waitFor(() => {
128
+ const portal = fullscreenContainer.querySelector(
129
+ "[data-testid='glide-data-editor-portal']",
130
+ );
131
+ expect(portal?.parentElement).toBe(fullscreenContainer);
132
+ });
133
+
134
+ act(() => {
135
+ fullscreenElement = null;
136
+ document.dispatchEvent(new Event("fullscreenchange"));
137
+ });
138
+
139
+ await waitFor(() => {
140
+ const portal = document.body.querySelector(
141
+ "[data-testid='glide-data-editor-portal']",
142
+ );
143
+ expect(portal?.parentElement).toBe(document.body);
144
+ });
145
+
146
+ fullscreenContainer.remove();
147
+ });
148
+
149
+ it("unmounts cleanly while fullscreen is active", async () => {
150
+ const fullscreenContainer = document.createElement("div");
151
+ document.body.appendChild(fullscreenContainer);
152
+
153
+ const { unmount } = render(
154
+ <TooltipProvider>
155
+ <GlideDataEditor {...editorProps} />
156
+ </TooltipProvider>,
157
+ );
158
+
159
+ act(() => {
160
+ fullscreenElement = fullscreenContainer;
161
+ document.dispatchEvent(new Event("fullscreenchange"));
162
+ });
163
+
164
+ await waitFor(() => {
165
+ expect(
166
+ fullscreenContainer.querySelector(
167
+ "[data-testid='glide-data-editor-portal']",
168
+ ),
169
+ ).not.toBeNull();
170
+ });
171
+
172
+ expect(() => {
173
+ act(() => unmount());
174
+ }).not.toThrow();
175
+
176
+ expect(
177
+ fullscreenContainer.querySelector(
178
+ "[data-testid='glide-data-editor-portal']",
179
+ ),
180
+ ).toBeNull();
181
+ expect(
182
+ document.body.querySelector("[data-testid='glide-data-editor-portal']"),
183
+ ).toBeNull();
184
+
185
+ fullscreenContainer.remove();
186
+ });
187
+ });
@@ -56,6 +56,7 @@ import {
56
56
  removeColumn,
57
57
  renameColumn,
58
58
  } from "./data-utils";
59
+ import { GlideDataEditorPortal } from "./glide-portal";
59
60
 
60
61
  interface GlideDataEditorProps<T> {
61
62
  data: T[];
@@ -88,6 +89,7 @@ export const GlideDataEditor = <T,>({
88
89
  }: GlideDataEditorProps<T>) => {
89
90
  const { theme } = useTheme();
90
91
  const dataEditorRef = useRef<DataEditorRef>(null);
92
+ const portalElementRef = useRef<HTMLDivElement>(null);
91
93
 
92
94
  const [menu, setMenu] = useState<{ col: number; bounds: Rectangle }>();
93
95
  const [showSearch, setShowSearch] = useState<boolean>(false);
@@ -613,9 +615,13 @@ export const GlideDataEditor = <T,>({
613
615
 
614
616
  return (
615
617
  <div className="relative w-full min-w-0">
618
+ <GlideDataEditorPortal portalRef={portalElementRef} />
616
619
  <ErrorBoundary>
617
620
  <DataEditor
618
621
  ref={dataEditorRef}
622
+ // Glide types portalElementRef as RefObject<HTMLElement> (non-null); React 19 refs include null.
623
+ // @ts-expect-error glide-data-grid stale RefObject typing
624
+ portalElementRef={portalElementRef}
619
625
  getCellContent={getCellContent}
620
626
  columns={columns}
621
627
  gridSelection={selection}