@marimo-team/islands 0.25.3-dev47 → 0.25.3-dev48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/{ErrorBoundary-B4QYAM0I.js → ErrorBoundary-DHfwqB0K.js} +1 -1
  2. package/dist/{code-visibility-BbrgXRMG.js → code-visibility-5llx6KCK.js} +1 -1
  3. package/dist/{common-DPSFvO_K.js → common-DBC1NAar.js} +89 -61
  4. package/dist/{data-grid-overlay-editor-D0m9lgVY.js → data-grid-overlay-editor-D01HzlXg.js} +1 -1
  5. package/dist/{feedback-button-BdqMy9oz.js → feedback-button-gTC25Y-p.js} +2 -2
  6. package/dist/{glide-data-editor-blkbnr2a.js → glide-data-editor-BXXphavz.js} +3 -3
  7. package/dist/{globals-CvaBSmzH.js → globals-BcxqBRmX.js} +1 -1
  8. package/dist/main.js +1491 -1403
  9. package/dist/{number-overlay-editor-yOSMD6Af.js → number-overlay-editor-CaspWg5p.js} +1 -1
  10. package/dist/{reveal-component-BHOMrvDj.js → reveal-component-CWk_paD2.js} +2 -2
  11. package/package.json +1 -1
  12. package/src/components/chat/__tests__/message-queue.test.tsx +17 -0
  13. package/src/components/chat/chat-panel.tsx +3 -7
  14. package/src/components/chat/chat-utils.ts +4 -8
  15. package/src/components/data-table/__tests__/context-menu-interaction.test.tsx +130 -0
  16. package/src/components/data-table/context-menu.tsx +36 -20
  17. package/src/components/editor/cell/CreateCellButton.tsx +8 -10
  18. package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +54 -0
  19. package/src/components/editor/cell/code/cell-editor.tsx +1 -1
  20. package/src/components/editor/cell/code/language-toggle.tsx +8 -7
  21. package/src/components/editor/file-tree/file-viewer.tsx +4 -2
  22. package/src/components/editor/output/console/ConsoleOutput.tsx +8 -18
  23. package/src/components/slides/slide-cell-view.tsx +1 -1
  24. package/src/core/static/files.ts +2 -0
  25. package/src/hooks/useInputHistory.ts +4 -7
  26. package/src/hooks/useResizeObserver.ts +4 -7
  27. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +2 -0
  28. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +241 -2
  29. package/src/plugins/impl/anywidget/runtime.ts +21 -9
  30. package/src/plugins/impl/anywidget/widget-binding.ts +160 -82
  31. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +5 -3
  32. package/src/plugins/impl/panel/PanelPlugin.tsx +15 -16
  33. package/src/plugins/impl/panel/__tests__/utils.test.ts +39 -1
  34. package/src/plugins/impl/panel/utils.ts +17 -0
  35. package/src/stories/__tests__/editor-story.test.tsx +33 -0
  36. package/src/stories/editor.stories.tsx +24 -17
@@ -1,6 +1,6 @@
1
1
  import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
- import { a as n } from "./glide-data-editor-blkbnr2a.js";
3
+ import { a as n } from "./glide-data-editor-BXXphavz.js";
4
4
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_27cffe7f1bc30287d59ac27db3240c5b/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
5
5
  var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
6
6
  name: "NumberOverlayEditorStyle",
@@ -4,14 +4,14 @@ import { t as o } from "./react-sHtZdjeP.js";
4
4
  import { t as s } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { h as c, t as l } from "./tooltip-BCntm-jf.js";
6
6
  import { O as u } from "./error-entries-D3ouOLDe.js";
7
- import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-DPSFvO_K.js";
7
+ import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-DBC1NAar.js";
8
8
  import { M as C } from "./select-D2iqeJYk.js";
9
9
  import { q as ee } from "./dropdown-menu-ugNoZmKx.js";
10
10
  import { _ as w } from "./toDate-C0jaysq2.js";
11
11
  import { t as T } from "./jsx-runtime-3BD1kT0s.js";
12
12
  import { X as E } from "./use-restore-focus-BhXhsNLV.js";
13
13
  import { A as D, C as O, b as k } from "./useTheme-BC8Lof0B.js";
14
- import { a as A, n as te, t as ne } from "./code-visibility-BbrgXRMG.js";
14
+ import { a as A, n as te, t as ne } from "./code-visibility-5llx6KCK.js";
15
15
  /* empty css */
16
16
  var j = c("sticky-note", [["path", {
17
17
  d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev47",
3
+ "version": "0.25.3-dev48",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -77,6 +77,23 @@ describe("useMessageQueue", () => {
77
77
  expect(result.current.messages).toEqual([]);
78
78
  });
79
79
 
80
+ it("preserves multiple enqueues and flushes before React renders", () => {
81
+ const { result } = renderHook(() => useMessageQueue());
82
+ const send = vi.fn();
83
+
84
+ act(() => {
85
+ result.current.enqueue([textPart("first")]);
86
+ result.current.enqueue([textPart("second")]);
87
+ result.current.flushNext(send);
88
+ result.current.flushNext(send);
89
+ });
90
+
91
+ expect(send).toHaveBeenNthCalledWith(1, [textPart("first")]);
92
+ expect(send).toHaveBeenNthCalledWith(2, [textPart("second")]);
93
+ expect(result.current.messages).toEqual([]);
94
+ expect(result.current.hasQueuedRef.current).toBe(false);
95
+ });
96
+
80
97
  it("flushNext on an empty queue is a no-op", () => {
81
98
  const { result } = renderHook(() => useMessageQueue());
82
99
  const send = vi.fn();
@@ -716,14 +716,10 @@ const ChatPanelBody = () => {
716
716
  );
717
717
 
718
718
  const isLoading = status === "submitted" || status === "streaming";
719
- // Read via a ref so the queue-vs-send decision stays correct even when it is
720
- // made after an `await` (e.g. resolving @-context), by which point the render
721
- // closure's `isLoading` may be stale.
722
- const isLoadingRef = useRef(isLoading);
723
- isLoadingRef.current = isLoading;
724
-
719
+ // useEvent reads the latest loading state, even when called after an await
720
+ // (e.g. resolving @-context) that outlives the original render's closure.
725
721
  const submitOrQueue = useEvent((parts: ChatMessagePart[]) => {
726
- if (isLoadingRef.current || hasQueuedRef.current) {
722
+ if (isLoading || hasQueuedRef.current) {
727
723
  enqueueUserMessage(parts);
728
724
  } else {
729
725
  sendUserMessage(parts);
@@ -294,16 +294,12 @@ export function useMessageQueue() {
294
294
  // invoked from a callback (e.g. `onFinish`) in the same tick as `enqueue`.
295
295
  const messagesRef = useRef<QueuedUserMessage[]>([]);
296
296
  const hasQueuedRef = useRef(false);
297
- messagesRef.current = messages;
298
- hasQueuedRef.current = messages.length > 0;
299
297
 
300
298
  const enqueue = useEvent((parts: ChatMessagePart[]) => {
301
- setMessages((prev) => {
302
- const next = [...prev, { id: generateUUID(), parts }];
303
- messagesRef.current = next;
304
- hasQueuedRef.current = true;
305
- return next;
306
- });
299
+ const next = [...messagesRef.current, { id: generateUUID(), parts }];
300
+ messagesRef.current = next;
301
+ hasQueuedRef.current = true;
302
+ setMessages(next);
307
303
  });
308
304
 
309
305
  const flushNext = useEvent((send: (parts: ChatMessagePart[]) => void) => {
@@ -0,0 +1,130 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { type Cell, createTable, getCoreRowModel } from "@tanstack/react-table";
4
+ import { fireEvent, render, screen } from "@testing-library/react";
5
+ import { useRef } from "react";
6
+ import { expect, it, vi } from "vitest";
7
+ import { SetupMocks } from "@/__mocks__/common";
8
+ import { DataTableContextMenu } from "../context-menu";
9
+
10
+ SetupMocks.resizeObserver();
11
+
12
+ const rows = [{ value: "first" }, { value: "second" }];
13
+ const columns = [
14
+ { accessorKey: "value", meta: { filterType: "text" as const } },
15
+ ];
16
+
17
+ function createCells() {
18
+ const table = createTable({
19
+ data: rows,
20
+ columns,
21
+ locale: "en-US",
22
+ getCoreRowModel: getCoreRowModel(),
23
+ onColumnFiltersChange: () => undefined,
24
+ state: {},
25
+ onStateChange: () => undefined,
26
+ renderFallbackValue: null,
27
+ });
28
+ return table.getRowModel().rows.map((row) => row.getAllCells()[0]);
29
+ }
30
+
31
+ function Harness({ cells }: { cells: Cell<(typeof rows)[number], unknown>[] }) {
32
+ const contextMenuRef = useRef<Cell<(typeof rows)[number], unknown> | null>(
33
+ null,
34
+ );
35
+ const tableRef = useRef<HTMLTableSectionElement>(null);
36
+ const tableBody = (
37
+ <tbody ref={tableRef}>
38
+ <tr>
39
+ <td>empty</td>
40
+ </tr>
41
+ {cells.map((cell) => {
42
+ return (
43
+ <tr key={cell.id}>
44
+ <td
45
+ data-cell-id={cell.id}
46
+ onContextMenu={() => {
47
+ contextMenuRef.current = cell;
48
+ }}
49
+ >
50
+ {String(cell.getValue())}
51
+ </td>
52
+ </tr>
53
+ );
54
+ })}
55
+ </tbody>
56
+ );
57
+ return (
58
+ <table>
59
+ <DataTableContextMenu
60
+ contextMenuRef={contextMenuRef}
61
+ tableRef={tableRef}
62
+ tableBody={tableBody}
63
+ copyAllCells={() => undefined}
64
+ />
65
+ </table>
66
+ );
67
+ }
68
+
69
+ it("filters the latest right-clicked cell when the menu is already open", () => {
70
+ const onFilter = vi.fn();
71
+ const table = createTable({
72
+ data: rows,
73
+ columns,
74
+ locale: "en-US",
75
+ getCoreRowModel: getCoreRowModel(),
76
+ onColumnFiltersChange: onFilter,
77
+ state: {},
78
+ onStateChange: () => undefined,
79
+ renderFallbackValue: null,
80
+ });
81
+ render(
82
+ <Harness
83
+ cells={table.getRowModel().rows.map((row) => row.getAllCells()[0])}
84
+ />,
85
+ );
86
+
87
+ const firstCell = screen.getByText("first");
88
+ const secondCell = screen.getByText("second");
89
+ fireEvent.contextMenu(firstCell);
90
+ expect(firstCell).toHaveClass("bg-(--green-4)");
91
+ expect(
92
+ screen.getByRole("menuitem", { name: "Filter by this value" }),
93
+ ).toBeInTheDocument();
94
+ fireEvent.contextMenu(secondCell);
95
+ expect(firstCell).not.toHaveClass("bg-(--green-4)");
96
+ expect(secondCell).toHaveClass("bg-(--green-4)");
97
+ fireEvent.click(
98
+ screen.getByRole("menuitem", { name: "Filter by this value" }),
99
+ );
100
+ expect(firstCell).not.toHaveClass("bg-(--green-4)");
101
+ expect(secondCell).not.toHaveClass("bg-(--green-4)");
102
+
103
+ const update = onFilter.mock.calls.at(-1)?.[0];
104
+ expect(update([])).toEqual([
105
+ {
106
+ id: "value",
107
+ value: { type: "text", operator: "equals", text: "second" },
108
+ },
109
+ ]);
110
+ });
111
+
112
+ it("does not reuse a previous cell after the menu closes", () => {
113
+ render(<Harness cells={createCells()} />);
114
+
115
+ const firstCell = screen.getByText("first");
116
+ fireEvent.contextMenu(firstCell);
117
+ expect(firstCell).toHaveClass("bg-(--green-4)");
118
+ fireEvent.keyDown(document.activeElement ?? document.body, {
119
+ key: "Escape",
120
+ });
121
+ expect(firstCell).not.toHaveClass("bg-(--green-4)");
122
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
123
+
124
+ // Right-clicking outside any data cell must not resurrect the old one
125
+ fireEvent.contextMenu(screen.getByText("empty"));
126
+ expect(firstCell).not.toHaveClass("bg-(--green-4)");
127
+ expect(
128
+ screen.queryByRole("menuitem", { name: "Filter by this value" }),
129
+ ).not.toBeInTheDocument();
130
+ });
@@ -12,7 +12,7 @@ import { Logger } from "@/utils/Logger";
12
12
  import type { Cell } from "@tanstack/react-table";
13
13
  import { useAtomValue } from "jotai";
14
14
  import { CopyIcon, FilterIcon, SquareStack } from "lucide-react";
15
- import type { RefObject } from "react";
15
+ import { type RefObject, useRef, useState } from "react";
16
16
  import useEvent from "react-use-event-hook";
17
17
  import {
18
18
  ContextMenu,
@@ -123,8 +123,21 @@ export const DataTableContextMenu = <TData,>({
123
123
  tableRef: RefObject<HTMLTableSectionElement | null>;
124
124
  copyAllCells: () => void;
125
125
  }) => {
126
+ const [activeCell, setActiveCell] = useState<Cell<TData, unknown> | null>(
127
+ null,
128
+ );
129
+ const highlightedCell = useRef<Element | null>(null);
126
130
  const handleContextMenuChange = useEvent((open: boolean) => {
131
+ highlightedCell.current?.classList.remove("bg-(--green-4)");
132
+ highlightedCell.current = null;
133
+ if (!open) {
134
+ // Forget the cell so a later right-click outside any cell can't reuse it
135
+ contextMenuRef.current = null;
136
+ return;
137
+ }
138
+
127
139
  const cell = contextMenuRef.current;
140
+ setActiveCell(cell);
128
141
  if (!cell) {
129
142
  return;
130
143
  }
@@ -138,42 +151,45 @@ export const DataTableContextMenu = <TData,>({
138
151
  return;
139
152
  }
140
153
 
141
- if (open) {
142
- cellElement.classList.add("bg-(--green-4)");
143
- } else {
144
- cellElement.classList.remove("bg-(--green-4)");
145
- }
154
+ cellElement.classList.add("bg-(--green-4)");
155
+ highlightedCell.current = cellElement;
146
156
  });
147
157
 
148
158
  return (
149
- <ContextMenu onOpenChange={handleContextMenuChange}>
150
- <ContextMenuTrigger asChild={true}>{tableBody}</ContextMenuTrigger>
159
+ // Open via the trigger so a right-click while already open retargets the
160
+ // menu; Radix only reports open changes, so use it for closing.
161
+ <ContextMenu
162
+ onOpenChange={(open) => {
163
+ if (!open) {
164
+ handleContextMenuChange(false);
165
+ }
166
+ }}
167
+ >
168
+ <ContextMenuTrigger
169
+ asChild={true}
170
+ onContextMenu={() => handleContextMenuChange(true)}
171
+ >
172
+ {tableBody}
173
+ </ContextMenuTrigger>
151
174
  <ContextMenuPortal>
152
- <CellContextMenu
153
- cellRef={contextMenuRef}
154
- copySelectedCells={copyAllCells}
155
- />
175
+ {activeCell && (
176
+ <CellContextMenu cell={activeCell} copySelectedCells={copyAllCells} />
177
+ )}
156
178
  </ContextMenuPortal>
157
179
  </ContextMenu>
158
180
  );
159
181
  };
160
182
 
161
183
  export const CellContextMenu = <TData,>({
162
- cellRef,
184
+ cell,
163
185
  copySelectedCells,
164
186
  }: {
165
- cellRef: RefObject<Cell<TData, unknown> | null>;
187
+ cell: Cell<TData, unknown>;
166
188
  copySelectedCells: () => void;
167
189
  }) => {
168
190
  const selectedCells = useAtomValue(selectedCellsAtom);
169
191
  const multipleSelectedCells = selectedCells.size > 1;
170
192
 
171
- const cell = cellRef.current;
172
- if (!cell) {
173
- Logger.error("No cell found in context menu");
174
- return;
175
- }
176
-
177
193
  const table = cell.getContext().table;
178
194
  const displayedValue = cell.getValue();
179
195
  const rawValue =
@@ -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
- const [justOpened, setJustOpened] = useState(false);
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
- setJustOpened(true);
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
- setJustOpened(true);
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 opened
132
- if (justOpened) {
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
- editorView={editorViewRef.current}
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
- editorView: EditorView | null;
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
- editorView,
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
- editorView={editorView}
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
- editorView={editorView}
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
- editorView={editorView}
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
- editorView: EditorView | null;
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
- editorView,
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
- internalValueRef.current = internalValue;
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, useRef, useState } from "react";
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 and cancel any pending clear
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 === 0 && timerRef.current === null) {
66
- timerRef.current = setTimeout(() => {
67
- timerRef.current = null;
68
- setDebouncedOutputs([]);
69
- }, CONSOLE_CLEAR_DEBOUNCE_MS);
60
+ if (outputs.length > 0) {
61
+ return;
70
62
  }
71
- return () => {
72
- if (timerRef.current !== null) {
73
- clearTimeout(timerRef.current);
74
- timerRef.current = null;
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
- editorView={editorViewRef.current}
120
+ editorViewRef={editorViewRef}
121
121
  currentLanguageAdapter={languageAdapter}
122
122
  onAfterToggle={afterToggleLanguage}
123
123
  className="flex items-center gap-1"
@@ -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 = historyRef.current;
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 = historyRef.current;
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) => {