@marimo-team/islands 0.25.3-dev51 → 0.25.3-dev55

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 (52) hide show
  1. package/dist/{ErrorBoundary-CbxCC2kk.js → ErrorBoundary-DL6Vkcwu.js} +1 -1
  2. package/dist/{chat-ui-C1Gjgokv.js → chat-ui-C0_lCzql.js} +52 -53
  3. package/dist/{code-visibility-DoZ9lxzx.js → code-visibility-CtaHItnb.js} +1 -1
  4. package/dist/{common-D1PacyXO.js → common-Bqcl1bUR.js} +6366 -6327
  5. package/dist/{data-grid-overlay-editor-B50UpkXd.js → data-grid-overlay-editor-C7NRbnX6.js} +1 -1
  6. package/dist/{dropdown-menu-MG5hiCIa.js → dropdown-menu-B3eUiVJB.js} +161 -153
  7. package/dist/{feedback-button-DqPqwryB.js → feedback-button-CpJHBUq4.js} +2 -2
  8. package/dist/{glide-data-editor-BxMhUd3K.js → glide-data-editor-Cd3d8sya.js} +4 -4
  9. package/dist/{globals-DTJa_doJ.js → globals-C5LrswXh.js} +1 -1
  10. package/dist/{html-to-image-By72ALyI.js → html-to-image-6xcoTKhC.js} +1 -1
  11. package/dist/main.js +2599 -2539
  12. package/dist/{number-overlay-editor-Z_H6x5b-.js → number-overlay-editor-Do7_pdca.js} +1 -1
  13. package/dist/{process-output-D-OMW5sV.js → process-output-BJRJFobf.js} +1 -1
  14. package/dist/{reveal-component-tAB5K3QE.js → reveal-component-CVYLwPCH.js} +12 -12
  15. package/package.json +1 -1
  16. package/src/components/app-config/ai-config.tsx +1 -1
  17. package/src/components/data-table/__tests__/export-actions.test.tsx +17 -0
  18. package/src/components/data-table/charts/charts.tsx +14 -19
  19. package/src/components/data-table/export-actions.tsx +5 -3
  20. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +24 -0
  21. package/src/components/editor/actions/pair-with-agent-modal.tsx +18 -15
  22. package/src/components/editor/cell/__tests__/useShouldShowInterrupt.test.ts +39 -0
  23. package/src/components/editor/cell/useShouldShowInterrupt.ts +2 -12
  24. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +30 -1
  25. package/src/components/editor/chrome/panels/packages-panel.tsx +4 -7
  26. package/src/components/editor/file-tree/__tests__/file-explorer-interactions.test.tsx +6 -0
  27. package/src/components/editor/file-tree/__tests__/use-file-search.test.tsx +91 -0
  28. package/src/components/editor/file-tree/file-explorer.tsx +10 -5
  29. package/src/components/editor/file-tree/use-file-search.ts +35 -21
  30. package/src/components/icons/__tests__/large-spinner.test.tsx +39 -0
  31. package/src/components/icons/large-spinner.tsx +3 -14
  32. package/src/core/network/useConnectionNotice.ts +8 -2
  33. package/src/hooks/__tests__/useDebounce.test.tsx +51 -0
  34. package/src/hooks/__tests__/useDeepCompareMemoize.test.tsx +39 -0
  35. package/src/hooks/__tests__/useDelayElapsed.test.tsx +28 -0
  36. package/src/hooks/__tests__/useInternalStateWithSync.test.tsx +20 -0
  37. package/src/hooks/__tests__/useScript.test.tsx +77 -0
  38. package/src/hooks/__tests__/useStateForKey.test.tsx +257 -0
  39. package/src/hooks/useDebounce.ts +5 -8
  40. package/src/hooks/useDeepCompareMemoize.ts +1 -1
  41. package/src/hooks/useDelayElapsed.ts +14 -10
  42. package/src/hooks/useInternalStateWithSync.ts +6 -6
  43. package/src/hooks/useScript.ts +67 -70
  44. package/src/hooks/useStateForKey.ts +93 -0
  45. package/src/plugins/impl/DataTablePlugin.tsx +14 -15
  46. package/src/plugins/impl/FileBrowserPlugin.tsx +4 -16
  47. package/src/plugins/impl/SliderPlugin.tsx +6 -8
  48. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +117 -1
  49. package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +45 -0
  50. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +24 -3
  51. package/src/plugins/impl/chat/__tests__/chat-prompts.test.tsx +93 -0
  52. package/src/plugins/impl/chat/chat-ui.tsx +10 -9
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useRequestClient } from "@/core/network/requests";
5
5
  import { useDebounce } from "@/hooks/useDebounce";
6
6
  import {
@@ -34,20 +34,29 @@ export function useFileSearch({
34
34
  }): { state: FileSearchState; refetch: () => void } {
35
35
  const { sendSearchFiles } = useRequestClient();
36
36
  const debouncedQuery = useDebounce(query, 300);
37
- const pending = useRef<Promise<void>>(Promise.resolve());
37
+ // Nullable so the ref is not built on every render
38
+ const pending = useRef<Promise<void> | null>(null);
38
39
  const [revision, setRevision] = useState(0);
39
40
  const refetch = useCallback(() => setRevision((value) => value + 1), []);
40
- const [result, setResult] = useState<FileSearchState>({ status: "idle" });
41
+ const requestIdentity = useMemo(
42
+ () => ({ query, tree, showHiddenFiles, revision, sendSearchFiles }),
43
+ [query, tree, showHiddenFiles, revision, sendSearchFiles],
44
+ );
45
+ const [result, setResult] = useState<{
46
+ requestIdentity: typeof requestIdentity;
47
+ state: FileSearchState;
48
+ }>();
41
49
 
42
50
  useEffect(() => {
51
+ // Read inputs through requestIdentity so it alone drives new requests
52
+ const { query, tree, showHiddenFiles, sendSearchFiles } = requestIdentity;
43
53
  let cancelled = false;
44
54
  if (!query || query !== debouncedQuery) {
45
55
  return;
46
56
  }
47
- setResult({ status: "loading", query });
48
57
  // Let the in-flight server scan finish before starting another. Superseded
49
58
  // queued queries and remaining roots are skipped, keeping scan work bounded.
50
- const request = pending.current.then(async () => {
59
+ const request = (pending.current ?? Promise.resolve()).then(async () => {
51
60
  const filesByPath = new Map<string, FileTreeNode>();
52
61
  let rootLimitReached = false;
53
62
  let failedRootCount = 0;
@@ -88,9 +97,12 @@ export function useFileSearch({
88
97
  if (!cancelled) {
89
98
  if (roots.length > 0 && failedRootCount === roots.length) {
90
99
  setResult({
91
- status: "error",
92
- query,
93
- error: new Error("Could not search any root"),
100
+ requestIdentity,
101
+ state: {
102
+ status: "error",
103
+ query,
104
+ error: new Error("Could not search any root"),
105
+ },
94
106
  });
95
107
  return;
96
108
  }
@@ -110,33 +122,35 @@ export function useFileSearch({
110
122
  rank(left) - rank(right) || left.name.localeCompare(right.name),
111
123
  );
112
124
  setResult({
113
- status: "success",
114
- isPartial: failedRootCount > 0,
115
- query,
116
- files: sortedFiles.slice(0, RESULT_LIMIT),
117
- hasMore: rootLimitReached || sortedFiles.length > RESULT_LIMIT,
125
+ requestIdentity,
126
+ state: {
127
+ status: "success",
128
+ isPartial: failedRootCount > 0,
129
+ query,
130
+ files: sortedFiles.slice(0, RESULT_LIMIT),
131
+ hasMore: rootLimitReached || sortedFiles.length > RESULT_LIMIT,
132
+ },
118
133
  });
119
134
  }
120
135
  });
121
136
  pending.current = request.catch((error: unknown) => {
122
137
  if (!cancelled) {
123
- setResult({ status: "error", query, error });
138
+ setResult({
139
+ requestIdentity,
140
+ state: { status: "error", query, error },
141
+ });
124
142
  }
125
143
  });
126
144
  return () => {
127
145
  cancelled = true;
128
146
  };
129
- }, [query, debouncedQuery, tree, showHiddenFiles, revision, sendSearchFiles]);
147
+ }, [debouncedQuery, requestIdentity]);
130
148
 
131
149
  if (!query) {
132
150
  return { state: { status: "idle" }, refetch };
133
151
  }
134
- if (
135
- query !== debouncedQuery ||
136
- result.status === "idle" ||
137
- result.query !== query
138
- ) {
152
+ if (query !== debouncedQuery || result?.requestIdentity !== requestIdentity) {
139
153
  return { state: { status: "loading", query }, refetch };
140
154
  }
141
- return { state: result, refetch };
155
+ return { state: result.state, refetch };
142
156
  }
@@ -0,0 +1,39 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, render, screen } from "@testing-library/react";
4
+ import { Profiler } from "react";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { LargeSpinner } from "../large-spinner";
7
+
8
+ describe("LargeSpinner", () => {
9
+ beforeEach(() => vi.useFakeTimers());
10
+ afterEach(() => vi.useRealTimers());
11
+
12
+ it("fades the old title without a second commit before the timer fires", () => {
13
+ const onRender = vi.fn();
14
+ const spinner = (title: string) => (
15
+ <Profiler id="spinner" onRender={onRender}>
16
+ <LargeSpinner title={title} />
17
+ </Profiler>
18
+ );
19
+ const { rerender } = render(spinner("Starting"));
20
+ onRender.mockClear();
21
+
22
+ rerender(spinner("Loading"));
23
+ expect(onRender).toHaveBeenCalledOnce();
24
+ expect(screen.getByText("Starting")).toHaveClass("opacity-0");
25
+
26
+ act(() => vi.advanceTimersByTime(300));
27
+ expect(screen.getByText("Loading")).toHaveClass("opacity-100");
28
+ });
29
+
30
+ it("restores visibility if the title changes back during the fade", () => {
31
+ const { rerender } = render(<LargeSpinner title="Starting" />);
32
+ rerender(<LargeSpinner title="Loading" />);
33
+ rerender(<LargeSpinner title="Starting" />);
34
+
35
+ expect(screen.getByText("Starting")).toHaveClass("opacity-100");
36
+ act(() => vi.advanceTimersByTime(300));
37
+ expect(screen.queryByText("Loading")).not.toBeInTheDocument();
38
+ });
39
+ });
@@ -1,23 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { Loader2Icon } from "lucide-react";
4
- import { useEffect, useState } from "react";
4
+ import { useDebounce } from "@/hooks/useDebounce";
5
5
  import { cn } from "@/utils/cn";
6
6
 
7
7
  export const LargeSpinner = ({ title }: { title?: string }) => {
8
- const [currentTitle, setCurrentTitle] = useState(title);
9
- const [isVisible, setIsVisible] = useState(true);
10
-
11
- useEffect(() => {
12
- if (title !== currentTitle) {
13
- setIsVisible(false);
14
- const timer = setTimeout(() => {
15
- setCurrentTitle(title);
16
- setIsVisible(true);
17
- }, 300); // Wait for fade out animation to complete
18
- return () => clearTimeout(timer);
19
- }
20
- }, [title, currentTitle]);
8
+ const currentTitle = useDebounce(title, 300);
9
+ const isVisible = title === currentTitle;
21
10
 
22
11
  return (
23
12
  <div className="flex flex-col h-full flex-1 items-center justify-center p-4">
@@ -17,11 +17,17 @@ export function useConnectionNotice(
17
17
  const notice = useAtomValue(connectionNoticeAtom);
18
18
  const connected = useAtomValue(isConnectedAtom);
19
19
  const elapsed = useDelayElapsed(notice?.pending ? delayMs : 0);
20
- const [previous, setPrevious] = useState<ConnectionNotice | null>(null);
20
+ const [previous, setPrevious] = useState<ConnectionNotice | null>(
21
+ elapsed ? notice : null,
22
+ );
23
+
24
+ const visibleNotice = elapsed ? notice : null;
25
+ if ((notice || !connected) && previous !== visibleNotice) {
26
+ setPrevious(visibleNotice);
27
+ }
21
28
 
22
29
  useEffect(() => {
23
30
  if (notice || !connected) {
24
- setPrevious(elapsed ? notice : null);
25
31
  return;
26
32
  }
27
33
  const timeout = setTimeout(() => setPrevious(null), 1500);
@@ -121,6 +121,57 @@ describe("useDebounceControlledState", () => {
121
121
 
122
122
  expect(onChange).not.toHaveBeenCalled();
123
123
  });
124
+
125
+ it("replaces an uncommitted draft when the external value changes", () => {
126
+ const onChange = vi.fn();
127
+ const { result, rerender } = renderHook(
128
+ ({ value }) =>
129
+ useDebounceControlledState({
130
+ initialValue: value,
131
+ onChange,
132
+ delay: 1000,
133
+ }),
134
+ { initialProps: { value: "initial" } },
135
+ );
136
+
137
+ act(() => result.current.onChange("draft"));
138
+ rerender({ value: "external" });
139
+ expect(result.current.value).toBe("external");
140
+ act(() => vi.runAllTimers());
141
+ expect(result.current.debouncedValue).toBe("external");
142
+ expect(onChange).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("handles NaN external values without repeatedly resetting the draft", () => {
146
+ const onChange = vi.fn();
147
+ const { result, rerender } = renderHook(
148
+ ({ value }) =>
149
+ useDebounceControlledState({ initialValue: value, onChange }),
150
+ { initialProps: { value: Number.NaN } },
151
+ );
152
+
153
+ expect(result.current.value).toBeNaN();
154
+ act(() => result.current.onChange(1));
155
+ rerender({ value: Number.NaN });
156
+ expect(result.current.value).toBe(1);
157
+ rerender({ value: 2 });
158
+ expect(result.current.value).toBe(2);
159
+ rerender({ value: Number.NaN });
160
+ expect(result.current.value).toBeNaN();
161
+ });
162
+
163
+ it("resets the draft when the external value changes from zero to negative zero", () => {
164
+ const onChange = vi.fn();
165
+ const { result, rerender } = renderHook(
166
+ ({ value }) =>
167
+ useDebounceControlledState({ initialValue: value, onChange }),
168
+ { initialProps: { value: 0 } },
169
+ );
170
+
171
+ act(() => result.current.onChange(1));
172
+ rerender({ value: -0 });
173
+ expect(result.current.value).toBe(-0);
174
+ });
124
175
  });
125
176
 
126
177
  describe("useDebouncedCallback", () => {
@@ -0,0 +1,39 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { renderHook } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { useDeepCompareMemoize } from "../useDeepCompareMemoize";
5
+
6
+ describe("useDeepCompareMemoize", () => {
7
+ it("keeps equal values stable and updates changed values immediately", () => {
8
+ const initial = { nested: [1, 2] };
9
+ const { result, rerender } = renderHook(
10
+ ({ value }) => useDeepCompareMemoize(value),
11
+ {
12
+ initialProps: { value: initial },
13
+ },
14
+ );
15
+ rerender({ value: { nested: [1, 2] } });
16
+ expect(result.current).toBe(initial);
17
+ const changed = { nested: [1, 3] };
18
+ rerender({ value: changed });
19
+ expect(result.current).toBe(changed);
20
+ rerender({ value: { nested: [1, 3] } });
21
+ expect(result.current).toBe(changed);
22
+ });
23
+
24
+ it("handles inline callbacks without causing additional renders", () => {
25
+ let renders = 0;
26
+ const { result, rerender } = renderHook(
27
+ ({ value }) => {
28
+ renders++;
29
+ return useDeepCompareMemoize({ callback: () => value });
30
+ },
31
+ { initialProps: { value: "initial" } },
32
+ );
33
+
34
+ const initialRenders = renders;
35
+ rerender({ value: "changed" });
36
+ expect(result.current.callback()).toBe("changed");
37
+ expect(renders).toBe(initialRenders + 1);
38
+ });
39
+ });
@@ -52,4 +52,32 @@ describe("useDelayElapsed", () => {
52
52
  // The pending timeout must be cleared, not left dangling.
53
53
  expect(vi.getTimerCount()).toBe(0);
54
54
  });
55
+
56
+ it("handles a NaN delay without resetting on every render", () => {
57
+ const { result, rerender } = renderHook(() => useDelayElapsed(Number.NaN));
58
+ expect(result.current).toBe(false);
59
+
60
+ act(() => vi.runAllTimers());
61
+ expect(result.current).toBe(true);
62
+ rerender();
63
+ expect(result.current).toBe(true);
64
+ expect(vi.getTimerCount()).toBe(0);
65
+ });
66
+
67
+ it("restarts a completed delay after the gate is temporarily disabled", () => {
68
+ const { result, rerender } = renderHook(
69
+ ({ delay }) => useDelayElapsed(delay),
70
+ { initialProps: { delay: 1000 } },
71
+ );
72
+ act(() => vi.advanceTimersByTime(1000));
73
+ expect(result.current).toBe(true);
74
+ rerender({ delay: 0 });
75
+ expect(result.current).toBe(true);
76
+ rerender({ delay: 1000 });
77
+ expect(result.current).toBe(false);
78
+ act(() => vi.advanceTimersByTime(999));
79
+ expect(result.current).toBe(false);
80
+ act(() => vi.advanceTimersByTime(1));
81
+ expect(result.current).toBe(true);
82
+ });
55
83
  });
@@ -32,6 +32,26 @@ describe("useInternalStateWithSync", () => {
32
32
  expect(result.current[0]).toBe("new value");
33
33
  });
34
34
 
35
+ it("preserves function values without calling them and resets when they change", () => {
36
+ const initial = vi.fn(() => "initial");
37
+ const draft = vi.fn(() => "draft");
38
+ const changed = vi.fn(() => "changed");
39
+ const { result, rerender } = renderHook(
40
+ ({ value }) => useInternalStateWithSync(value),
41
+ { initialProps: { value: initial } },
42
+ );
43
+
44
+ expect(result.current[0]).toBe(initial);
45
+ act(() => result.current[1](() => draft));
46
+ rerender({ value: initial });
47
+ expect(result.current[0]).toBe(draft);
48
+ rerender({ value: changed });
49
+ expect(result.current[0]).toBe(changed);
50
+ expect(initial).not.toHaveBeenCalled();
51
+ expect(draft).not.toHaveBeenCalled();
52
+ expect(changed).not.toHaveBeenCalled();
53
+ });
54
+
35
55
  it("should not update internal state if initial value remains the same", () => {
36
56
  const { result, rerender } = renderHook(
37
57
  ({ value }) => useInternalStateWithSync(value),
@@ -0,0 +1,77 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { useScript } from "../useScript";
5
+
6
+ afterEach(() => {
7
+ document.querySelectorAll("script").forEach((script) => script.remove());
8
+ });
9
+
10
+ describe("useScript", () => {
11
+ it("observes loading and completion of a new script", async () => {
12
+ const { result } = renderHook(() => useScript("/first.js"));
13
+ expect(result.current).toBe("loading");
14
+ await act(async () => {
15
+ document.querySelector("script")?.dispatchEvent(new Event("load"));
16
+ });
17
+ expect(result.current).toBe("ready");
18
+ });
19
+
20
+ it("uses the new source's status when the source changes", async () => {
21
+ const { result, rerender } = renderHook(({ src }) => useScript(src), {
22
+ initialProps: { src: "/first.js" },
23
+ });
24
+ await act(async () => {
25
+ document.querySelector("script")?.dispatchEvent(new Event("load"));
26
+ });
27
+ expect(result.current).toBe("ready");
28
+ rerender({ src: "/second.js" });
29
+ expect(result.current).toBe("loading");
30
+ await act(async () => {
31
+ document
32
+ .querySelector('script[src="/second.js"]')
33
+ ?.dispatchEvent(new Event("error"));
34
+ });
35
+ expect(result.current).toBe("error");
36
+ });
37
+
38
+ it("shares an existing script and only removes scripts it owns", async () => {
39
+ const owner = renderHook(() =>
40
+ useScript("/first.js", { removeOnUnmount: true }),
41
+ );
42
+ const subscriber = renderHook(() =>
43
+ useScript("/first.js", { removeOnUnmount: true }),
44
+ );
45
+ expect(document.querySelectorAll("script")).toHaveLength(1);
46
+ await act(async () => {
47
+ document.querySelector("script")?.dispatchEvent(new Event("load"));
48
+ });
49
+ expect(subscriber.result.current).toBe("ready");
50
+ subscriber.unmount();
51
+ expect(document.querySelectorAll("script")).toHaveLength(1);
52
+ owner.unmount();
53
+ expect(document.querySelectorAll("script")).toHaveLength(0);
54
+ });
55
+
56
+ it("settles for remaining subscribers when the first unmounts mid-load", async () => {
57
+ const first = renderHook(() => useScript("/shared.js"));
58
+ const second = renderHook(() => useScript("/shared.js"));
59
+ first.unmount();
60
+ expect(second.result.current).toBe("loading");
61
+ await act(async () => {
62
+ document.querySelector("script")?.dispatchEvent(new Event("load"));
63
+ });
64
+ expect(second.result.current).toBe("ready");
65
+ // A later subscriber sees the settled status
66
+ expect(renderHook(() => useScript("/shared.js")).result.current).toBe(
67
+ "ready",
68
+ );
69
+ });
70
+
71
+ it("handles sources that are not valid inside a CSS selector", () => {
72
+ const src = '/odd"name].js';
73
+ const { result } = renderHook(() => useScript(src));
74
+ expect(result.current).toBe("loading");
75
+ expect(document.querySelector("script")?.getAttribute("src")).toBe(src);
76
+ });
77
+ });