@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.
- package/dist/{ErrorBoundary-CbxCC2kk.js → ErrorBoundary-DL6Vkcwu.js} +1 -1
- package/dist/{chat-ui-C1Gjgokv.js → chat-ui-C0_lCzql.js} +52 -53
- package/dist/{code-visibility-DoZ9lxzx.js → code-visibility-CtaHItnb.js} +1 -1
- package/dist/{common-D1PacyXO.js → common-Bqcl1bUR.js} +6366 -6327
- package/dist/{data-grid-overlay-editor-B50UpkXd.js → data-grid-overlay-editor-C7NRbnX6.js} +1 -1
- package/dist/{dropdown-menu-MG5hiCIa.js → dropdown-menu-B3eUiVJB.js} +161 -153
- package/dist/{feedback-button-DqPqwryB.js → feedback-button-CpJHBUq4.js} +2 -2
- package/dist/{glide-data-editor-BxMhUd3K.js → glide-data-editor-Cd3d8sya.js} +4 -4
- package/dist/{globals-DTJa_doJ.js → globals-C5LrswXh.js} +1 -1
- package/dist/{html-to-image-By72ALyI.js → html-to-image-6xcoTKhC.js} +1 -1
- package/dist/main.js +2599 -2539
- package/dist/{number-overlay-editor-Z_H6x5b-.js → number-overlay-editor-Do7_pdca.js} +1 -1
- package/dist/{process-output-D-OMW5sV.js → process-output-BJRJFobf.js} +1 -1
- package/dist/{reveal-component-tAB5K3QE.js → reveal-component-CVYLwPCH.js} +12 -12
- package/package.json +1 -1
- package/src/components/app-config/ai-config.tsx +1 -1
- package/src/components/data-table/__tests__/export-actions.test.tsx +17 -0
- package/src/components/data-table/charts/charts.tsx +14 -19
- package/src/components/data-table/export-actions.tsx +5 -3
- package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +24 -0
- package/src/components/editor/actions/pair-with-agent-modal.tsx +18 -15
- package/src/components/editor/cell/__tests__/useShouldShowInterrupt.test.ts +39 -0
- package/src/components/editor/cell/useShouldShowInterrupt.ts +2 -12
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +30 -1
- package/src/components/editor/chrome/panels/packages-panel.tsx +4 -7
- package/src/components/editor/file-tree/__tests__/file-explorer-interactions.test.tsx +6 -0
- package/src/components/editor/file-tree/__tests__/use-file-search.test.tsx +91 -0
- package/src/components/editor/file-tree/file-explorer.tsx +10 -5
- package/src/components/editor/file-tree/use-file-search.ts +35 -21
- package/src/components/icons/__tests__/large-spinner.test.tsx +39 -0
- package/src/components/icons/large-spinner.tsx +3 -14
- package/src/core/network/useConnectionNotice.ts +8 -2
- package/src/hooks/__tests__/useDebounce.test.tsx +51 -0
- package/src/hooks/__tests__/useDeepCompareMemoize.test.tsx +39 -0
- package/src/hooks/__tests__/useDelayElapsed.test.tsx +28 -0
- package/src/hooks/__tests__/useInternalStateWithSync.test.tsx +20 -0
- package/src/hooks/__tests__/useScript.test.tsx +77 -0
- package/src/hooks/__tests__/useStateForKey.test.tsx +257 -0
- package/src/hooks/useDebounce.ts +5 -8
- package/src/hooks/useDeepCompareMemoize.ts +1 -1
- package/src/hooks/useDelayElapsed.ts +14 -10
- package/src/hooks/useInternalStateWithSync.ts +6 -6
- package/src/hooks/useScript.ts +67 -70
- package/src/hooks/useStateForKey.ts +93 -0
- package/src/plugins/impl/DataTablePlugin.tsx +14 -15
- package/src/plugins/impl/FileBrowserPlugin.tsx +4 -16
- package/src/plugins/impl/SliderPlugin.tsx +6 -8
- package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +117 -1
- package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +45 -0
- package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +24 -3
- package/src/plugins/impl/chat/__tests__/chat-prompts.test.tsx +93 -0
- package/src/plugins/impl/chat/chat-ui.tsx +10 -9
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
renderHook,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react";
|
|
9
|
+
import {
|
|
10
|
+
Profiler,
|
|
11
|
+
startTransition,
|
|
12
|
+
StrictMode,
|
|
13
|
+
Suspense,
|
|
14
|
+
useLayoutEffect,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
import { describe, expect, it, vi } from "vitest";
|
|
18
|
+
import { Deferred } from "@/utils/Deferred";
|
|
19
|
+
import { useStateForKey } from "../useStateForKey";
|
|
20
|
+
|
|
21
|
+
describe("useStateForKey", () => {
|
|
22
|
+
it("preserves edits for the same key and creates fresh state on key changes", () => {
|
|
23
|
+
const initialize = vi.fn(() => new Set<string>());
|
|
24
|
+
const { result, rerender } = renderHook(
|
|
25
|
+
({ key }) => useStateForKey(key, initialize),
|
|
26
|
+
{ initialProps: { key: "first" } },
|
|
27
|
+
);
|
|
28
|
+
const initial = result.current[0];
|
|
29
|
+
act(() => result.current[1](new Set(["expanded"])));
|
|
30
|
+
rerender({ key: "first" });
|
|
31
|
+
expect(result.current[0]).toEqual(new Set(["expanded"]));
|
|
32
|
+
expect(initialize).toHaveBeenCalledOnce();
|
|
33
|
+
|
|
34
|
+
rerender({ key: "second" });
|
|
35
|
+
expect(result.current[0]).toEqual(new Set());
|
|
36
|
+
expect(result.current[0]).not.toBe(initial);
|
|
37
|
+
expect(initialize).toHaveBeenCalledTimes(2);
|
|
38
|
+
|
|
39
|
+
rerender({ key: "first" });
|
|
40
|
+
expect(result.current[0]).toEqual(new Set());
|
|
41
|
+
expect(initialize).toHaveBeenCalledTimes(3);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("preserves queued functional updates and ignores setters from another key", () => {
|
|
45
|
+
const { result, rerender } = renderHook(
|
|
46
|
+
({ key }) => useStateForKey(key, () => 0),
|
|
47
|
+
{ initialProps: { key: "first" } },
|
|
48
|
+
);
|
|
49
|
+
const oldSetter = result.current[1];
|
|
50
|
+
act(() => {
|
|
51
|
+
result.current[1]((value) => value + 1);
|
|
52
|
+
result.current[1]((value) => value + 1);
|
|
53
|
+
});
|
|
54
|
+
expect(result.current[0]).toBe(2);
|
|
55
|
+
|
|
56
|
+
rerender({ key: "second" });
|
|
57
|
+
const staleUpdate = vi.fn(() => 99);
|
|
58
|
+
act(() => oldSetter(staleUpdate));
|
|
59
|
+
act(() => oldSetter(99));
|
|
60
|
+
expect(result.current[0]).toBe(0);
|
|
61
|
+
expect(staleUpdate).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses Object.is for NaN and signed zero keys", () => {
|
|
65
|
+
const { result, rerender } = renderHook(
|
|
66
|
+
({ key }) => useStateForKey(key, () => "initial"),
|
|
67
|
+
{ initialProps: { key: Number.NaN } },
|
|
68
|
+
);
|
|
69
|
+
act(() => result.current[1]("draft"));
|
|
70
|
+
rerender({ key: Number.NaN });
|
|
71
|
+
expect(result.current[0]).toBe("draft");
|
|
72
|
+
|
|
73
|
+
rerender({ key: 0 });
|
|
74
|
+
expect(result.current[0]).toBe("initial");
|
|
75
|
+
act(() => result.current[1]("draft"));
|
|
76
|
+
rerender({ key: -0 });
|
|
77
|
+
expect(result.current[0]).toBe("initial");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("stores function values without invoking them", () => {
|
|
81
|
+
const initial = vi.fn();
|
|
82
|
+
const changed = vi.fn();
|
|
83
|
+
const { result, rerender } = renderHook(
|
|
84
|
+
({ key, value }) => useStateForKey(key, () => value),
|
|
85
|
+
{ initialProps: { key: "first", value: initial } },
|
|
86
|
+
);
|
|
87
|
+
expect(result.current[0]).toBe(initial);
|
|
88
|
+
act(() => result.current[1](() => changed));
|
|
89
|
+
expect(result.current[0]).toBe(changed);
|
|
90
|
+
rerender({ key: "second", value: initial });
|
|
91
|
+
expect(result.current[0]).toBe(initial);
|
|
92
|
+
expect(initial).not.toHaveBeenCalled();
|
|
93
|
+
expect(changed).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses a changed initializer only after the key changes", () => {
|
|
97
|
+
const first = vi.fn(() => "first default");
|
|
98
|
+
const second = vi.fn(() => "second default");
|
|
99
|
+
const { result, rerender } = renderHook(
|
|
100
|
+
({ key, initialize }) => useStateForKey(key, initialize),
|
|
101
|
+
{ initialProps: { key: "A", initialize: first } },
|
|
102
|
+
);
|
|
103
|
+
act(() => result.current[1]("draft"));
|
|
104
|
+
rerender({ key: "A", initialize: second });
|
|
105
|
+
expect(result.current[0]).toBe("draft");
|
|
106
|
+
expect(second).not.toHaveBeenCalled();
|
|
107
|
+
rerender({ key: "B", initialize: second });
|
|
108
|
+
expect(result.current[0]).toBe("second default");
|
|
109
|
+
expect(second).toHaveBeenCalledOnce();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("compares object keys by reference rather than their contents", () => {
|
|
113
|
+
const key = { id: "A" };
|
|
114
|
+
const { result, rerender } = renderHook(
|
|
115
|
+
({ key }) => useStateForKey(key, () => 0),
|
|
116
|
+
{ initialProps: { key } },
|
|
117
|
+
);
|
|
118
|
+
act(() => result.current[1](1));
|
|
119
|
+
rerender({ key });
|
|
120
|
+
expect(result.current[0]).toBe(1);
|
|
121
|
+
rerender({ key: { id: "A" } });
|
|
122
|
+
expect(result.current[0]).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps setters stable for the same key and accepts old setters when that key returns", () => {
|
|
126
|
+
const { result, rerender } = renderHook(
|
|
127
|
+
({ key }) => useStateForKey(key, () => 0),
|
|
128
|
+
{ initialProps: { key: "A" } },
|
|
129
|
+
);
|
|
130
|
+
const setA = result.current[1];
|
|
131
|
+
act(() => setA(1));
|
|
132
|
+
rerender({ key: "A" });
|
|
133
|
+
expect(result.current[1]).toBe(setA);
|
|
134
|
+
rerender({ key: "B" });
|
|
135
|
+
expect(result.current[1]).not.toBe(setA);
|
|
136
|
+
act(() => setA(2));
|
|
137
|
+
expect(result.current[0]).toBe(0);
|
|
138
|
+
rerender({ key: "A" });
|
|
139
|
+
act(() => setA(3));
|
|
140
|
+
expect(result.current[0]).toBe(3);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("retries the owner without rendering or committing stale state to children", () => {
|
|
144
|
+
const onOwnerRender = vi.fn();
|
|
145
|
+
const onChildRender = vi.fn();
|
|
146
|
+
const onCommit = vi.fn();
|
|
147
|
+
const onLayout = vi.fn();
|
|
148
|
+
function Child({ owner, value }: { owner: string; value: string }) {
|
|
149
|
+
onChildRender(owner, value);
|
|
150
|
+
useLayoutEffect(() => onLayout(owner, value), [owner, value]);
|
|
151
|
+
return <span>{value}</span>;
|
|
152
|
+
}
|
|
153
|
+
function Owner({ owner }: { owner: string }) {
|
|
154
|
+
onOwnerRender();
|
|
155
|
+
const [value, setValue] = useStateForKey(owner, () => `${owner} default`);
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<button onClick={() => setValue("draft")}>Edit</button>
|
|
159
|
+
<Child owner={owner} value={value} />
|
|
160
|
+
</>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const content = (owner: string) => (
|
|
164
|
+
<Profiler id="owner" onRender={onCommit}>
|
|
165
|
+
<Owner owner={owner} />
|
|
166
|
+
</Profiler>
|
|
167
|
+
);
|
|
168
|
+
const { rerender } = render(content("A"));
|
|
169
|
+
const button = screen.getByRole("button", { name: "Edit" });
|
|
170
|
+
fireEvent.click(button);
|
|
171
|
+
expect(screen.getByText("draft")).toBeInTheDocument();
|
|
172
|
+
onOwnerRender.mockClear();
|
|
173
|
+
onChildRender.mockClear();
|
|
174
|
+
onCommit.mockClear();
|
|
175
|
+
onLayout.mockClear();
|
|
176
|
+
|
|
177
|
+
rerender(content("B"));
|
|
178
|
+
expect(onOwnerRender.mock.calls.length).toBeGreaterThan(1);
|
|
179
|
+
expect(onChildRender.mock.calls).toEqual([["B", "B default"]]);
|
|
180
|
+
expect(onLayout.mock.calls).toEqual([["B", "B default"]]);
|
|
181
|
+
expect(onCommit).toHaveBeenCalledOnce();
|
|
182
|
+
expect(screen.getByRole("button", { name: "Edit" })).toBe(button);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("keeps state correct when Strict Mode repeats initialization and updates", () => {
|
|
186
|
+
const initialize = vi.fn(() => new Set<string>());
|
|
187
|
+
const { result, rerender } = renderHook(
|
|
188
|
+
({ key }) => useStateForKey(key, initialize),
|
|
189
|
+
{ initialProps: { key: "A" }, wrapper: StrictMode },
|
|
190
|
+
);
|
|
191
|
+
expect(initialize.mock.calls.length).toBeGreaterThan(1);
|
|
192
|
+
act(() => result.current[1]((previous) => new Set([...previous, "draft"])));
|
|
193
|
+
expect(result.current[0]).toEqual(new Set(["draft"]));
|
|
194
|
+
rerender({ key: "B" });
|
|
195
|
+
expect(result.current[0]).toEqual(new Set());
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each(["completes", "is canceled"])(
|
|
199
|
+
"preserves visible edits when a suspended transition %s",
|
|
200
|
+
async (outcome) => {
|
|
201
|
+
const pending = new Deferred<void>();
|
|
202
|
+
let blocked = true;
|
|
203
|
+
const suspended = vi.fn();
|
|
204
|
+
function Pending({ owner }: { owner: string }) {
|
|
205
|
+
if (owner === "B" && blocked) {
|
|
206
|
+
suspended();
|
|
207
|
+
throw pending.promise;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function Owner({ owner }: { owner: string }) {
|
|
212
|
+
const [value, setValue] = useStateForKey(owner, () => 0);
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
<button onClick={() => setValue((previous) => previous + 1)}>
|
|
216
|
+
{owner}:{value}
|
|
217
|
+
</button>
|
|
218
|
+
<Pending owner={owner} />
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
function Harness() {
|
|
223
|
+
const [owner, setOwner] = useState("A");
|
|
224
|
+
return (
|
|
225
|
+
<>
|
|
226
|
+
<button onClick={() => startTransition(() => setOwner("B"))}>
|
|
227
|
+
Switch owner
|
|
228
|
+
</button>
|
|
229
|
+
<button onClick={() => setOwner("A")}>Keep owner</button>
|
|
230
|
+
<Suspense fallback="Loading">
|
|
231
|
+
<Owner owner={owner} />
|
|
232
|
+
</Suspense>
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
render(<Harness />);
|
|
237
|
+
fireEvent.click(screen.getByRole("button", { name: "A:0" }));
|
|
238
|
+
fireEvent.click(screen.getByRole("button", { name: "Switch owner" }));
|
|
239
|
+
expect(suspended).toHaveBeenCalled();
|
|
240
|
+
fireEvent.click(screen.getByRole("button", { name: "A:1" }));
|
|
241
|
+
expect(screen.getByRole("button", { name: "A:2" })).toBeInTheDocument();
|
|
242
|
+
if (outcome === "is canceled") {
|
|
243
|
+
fireEvent.click(screen.getByRole("button", { name: "Keep owner" }));
|
|
244
|
+
}
|
|
245
|
+
await act(async () => {
|
|
246
|
+
blocked = false;
|
|
247
|
+
pending.resolve();
|
|
248
|
+
await pending.promise;
|
|
249
|
+
});
|
|
250
|
+
expect(
|
|
251
|
+
screen.getByRole("button", {
|
|
252
|
+
name: outcome === "completes" ? "B:0" : "A:2",
|
|
253
|
+
}),
|
|
254
|
+
).toBeInTheDocument();
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
});
|
package/src/hooks/useDebounce.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { debounce } from "lodash-es";
|
|
3
3
|
import { useEffect, useMemo, useState } from "react";
|
|
4
4
|
import useEvent from "react-use-event-hook";
|
|
5
|
+
import { useInternalStateWithSync } from "./useInternalStateWithSync";
|
|
5
6
|
|
|
6
7
|
export function useDebounce<T>(value: T, delay: number): T {
|
|
7
8
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
@@ -41,18 +42,14 @@ export function useDebounceControlledState<T>(opts: {
|
|
|
41
42
|
disabled?: boolean;
|
|
42
43
|
}) {
|
|
43
44
|
const { initialValue, onChange, delay, disabled } = opts;
|
|
44
|
-
const [internalValue, setInternalValue] =
|
|
45
|
+
const [internalValue, setInternalValue] = useInternalStateWithSync(
|
|
46
|
+
initialValue,
|
|
47
|
+
Object.is,
|
|
48
|
+
);
|
|
45
49
|
const debouncedValue = useDebounce(internalValue, delay || 200);
|
|
46
50
|
|
|
47
51
|
const onUpdate = useEvent(onChange);
|
|
48
52
|
|
|
49
|
-
// Handle external changes:
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
// TODO: Replace this synchronization with a controlled-state primitive.
|
|
52
|
-
// oxlint-disable-next-line react/no-deriving-state-in-effects -- Synchronize the debounced draft when the controlled value changes.
|
|
53
|
-
setInternalValue(initialValue);
|
|
54
|
-
}, [initialValue]);
|
|
55
|
-
|
|
56
53
|
// Handle debounced updates for user input
|
|
57
54
|
useEffect(() => {
|
|
58
55
|
if (disabled) {
|
|
@@ -12,7 +12,7 @@ import React from "react";
|
|
|
12
12
|
* Useful when the re-render if more expensive than the deep compare.
|
|
13
13
|
*/
|
|
14
14
|
export function useDeepCompareMemoize<T>(value: T) {
|
|
15
|
-
const ref = React.useRef
|
|
15
|
+
const ref = React.useRef(value);
|
|
16
16
|
|
|
17
17
|
if (!dequal(value, ref.current)) {
|
|
18
18
|
ref.current = value;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Returns `false` until `delayMs` has elapsed since mount (or since `delayMs`
|
|
@@ -8,20 +8,24 @@ import { useLayoutEffect, useState } from "react";
|
|
|
8
8
|
* `delayMs <= 0`. Useful for suppressing spinner/fallback flashes.
|
|
9
9
|
*/
|
|
10
10
|
export function useDelayElapsed(delayMs: number): boolean {
|
|
11
|
-
const [
|
|
11
|
+
const [timer, setTimer] = useState({ delayMs, elapsed: delayMs <= 0 });
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// Reset during render so a changed delay never commits a stale `elapsed`
|
|
14
|
+
// (e.g. a gate re-closing shows no fallback flash before paint)
|
|
15
|
+
if (!Object.is(timer.delayMs, delayMs)) {
|
|
16
|
+
setTimer({ delayMs, elapsed: delayMs <= 0 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
17
20
|
if (delayMs <= 0) {
|
|
18
|
-
setElapsed(true);
|
|
19
21
|
return;
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const timeout = setTimeout(
|
|
24
|
+
() => setTimer({ delayMs, elapsed: true }),
|
|
25
|
+
delayMs,
|
|
26
|
+
);
|
|
23
27
|
return () => clearTimeout(timeout);
|
|
24
28
|
}, [delayMs]);
|
|
25
29
|
|
|
26
|
-
return elapsed;
|
|
30
|
+
return timer.elapsed;
|
|
27
31
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from "react";
|
|
3
3
|
|
|
4
4
|
type EqualityFn<T> = (a: T, b: T) => boolean;
|
|
5
5
|
|
|
@@ -13,13 +13,13 @@ export function useInternalStateWithSync<T>(
|
|
|
13
13
|
initial: T,
|
|
14
14
|
equalityFn: EqualityFn<T> = defaultEqualityFn,
|
|
15
15
|
) {
|
|
16
|
-
const [state, setState] = useState(initial);
|
|
17
|
-
const
|
|
16
|
+
const [state, setState] = useState(() => initial);
|
|
17
|
+
const [previousInitial, setPreviousInitial] = useState(() => initial);
|
|
18
18
|
|
|
19
19
|
// Whenever the outside state changes, update the internal state
|
|
20
|
-
if (!equalityFn(
|
|
21
|
-
setState(initial);
|
|
22
|
-
|
|
20
|
+
if (!equalityFn(previousInitial, initial)) {
|
|
21
|
+
setState(() => initial);
|
|
22
|
+
setPreviousInitial(() => initial);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
return [state, setState] as const;
|
package/src/hooks/useScript.ts
CHANGED
|
@@ -1,90 +1,87 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import
|
|
2
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
3
3
|
|
|
4
4
|
type ScriptStatus = "loading" | "ready" | "error" | "unknown";
|
|
5
5
|
interface ScriptOptions {
|
|
6
6
|
removeOnUnmount?: boolean;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
interface ScriptEntry {
|
|
10
|
+
element: HTMLScriptElement;
|
|
11
|
+
listeners: Set<() => void>;
|
|
12
|
+
/** Whether useScript created the element, and so may remove it. */
|
|
13
|
+
owned: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
const defaultOptions = { removeOnUnmount: false };
|
|
10
17
|
|
|
18
|
+
// One entry per src, so reading the status on render is a map lookup
|
|
19
|
+
const scripts = new Map<string, ScriptEntry>();
|
|
20
|
+
|
|
21
|
+
function findScript(src: string): HTMLScriptElement | undefined {
|
|
22
|
+
// Compare attributes rather than building a selector, which `src` could break
|
|
23
|
+
return [...document.scripts].find(
|
|
24
|
+
(script) => script.getAttribute("src") === src,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function acquireScript(src: string): ScriptEntry {
|
|
29
|
+
const cached = scripts.get(src);
|
|
30
|
+
if (cached?.element.isConnected) {
|
|
31
|
+
return cached;
|
|
32
|
+
}
|
|
33
|
+
const existing = findScript(src);
|
|
34
|
+
const element = existing ?? document.createElement("script");
|
|
35
|
+
const entry: ScriptEntry = {
|
|
36
|
+
element,
|
|
37
|
+
listeners: new Set(),
|
|
38
|
+
owned: !existing,
|
|
39
|
+
};
|
|
40
|
+
const setStatus = (status: ScriptStatus) => {
|
|
41
|
+
element.dataset.status = status;
|
|
42
|
+
entry.listeners.forEach((listener) => listener());
|
|
43
|
+
};
|
|
44
|
+
// Listen for the script's lifetime rather than per subscriber, so the
|
|
45
|
+
// status still settles if the first subscriber unmounts mid-load.
|
|
46
|
+
element.addEventListener("load", () => setStatus("ready"), { once: true });
|
|
47
|
+
element.addEventListener("error", () => setStatus("error"), { once: true });
|
|
48
|
+
if (!existing) {
|
|
49
|
+
element.src = src;
|
|
50
|
+
element.async = true;
|
|
51
|
+
element.dataset.status = "loading";
|
|
52
|
+
document.body.append(element);
|
|
53
|
+
}
|
|
54
|
+
scripts.set(src, entry);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
|
|
11
58
|
export function useScript(
|
|
12
59
|
src: string,
|
|
13
60
|
options: ScriptOptions = defaultOptions,
|
|
14
61
|
) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
);
|
|
19
|
-
return (
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
React.useEffect(() => {
|
|
23
|
-
let script = document.querySelector<HTMLScriptElement>(
|
|
24
|
-
`script[src="${src}"]`,
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const updateStatus = (newStatus: ScriptStatus) => {
|
|
28
|
-
if (script) {
|
|
29
|
-
script.dataset.status = newStatus;
|
|
30
|
-
setStatus(newStatus);
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const handleScriptLoad = () => updateStatus("ready");
|
|
35
|
-
const handleScriptError = () => updateStatus("error");
|
|
36
|
-
|
|
37
|
-
// observes DOM changes and updates the status
|
|
38
|
-
const observeScript = (script: HTMLScriptElement) => {
|
|
39
|
-
const observer = new MutationObserver(() => {
|
|
40
|
-
const newStatus = script.dataset.status;
|
|
41
|
-
if (newStatus) {
|
|
42
|
-
setStatus(newStatus as ScriptStatus);
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
observer.observe(script, {
|
|
46
|
-
attributes: true,
|
|
47
|
-
attributeFilter: ["data-status"],
|
|
48
|
-
});
|
|
49
|
-
return observer;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
if (!script) {
|
|
53
|
-
script = document.createElement("script");
|
|
54
|
-
script.src = src;
|
|
55
|
-
script.async = true;
|
|
56
|
-
updateStatus("loading");
|
|
57
|
-
document.body.append(script);
|
|
58
|
-
|
|
59
|
-
script.addEventListener("load", handleScriptLoad);
|
|
60
|
-
script.addEventListener("error", handleScriptError);
|
|
61
|
-
|
|
62
|
-
const observer = observeScript(script);
|
|
62
|
+
const { removeOnUnmount } = options;
|
|
63
|
+
const getSnapshot = useCallback((): ScriptStatus => {
|
|
64
|
+
const cached = scripts.get(src)?.element;
|
|
65
|
+
const element = cached?.isConnected ? cached : findScript(src);
|
|
66
|
+
return (element?.dataset.status as ScriptStatus) || "unknown";
|
|
67
|
+
}, [src]);
|
|
63
68
|
|
|
69
|
+
const subscribe = useCallback(
|
|
70
|
+
(onChange: () => void) => {
|
|
71
|
+
const entry = acquireScript(src);
|
|
72
|
+
entry.listeners.add(onChange);
|
|
64
73
|
return () => {
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (options.removeOnUnmount) {
|
|
71
|
-
script.remove();
|
|
74
|
+
entry.listeners.delete(onChange);
|
|
75
|
+
if (removeOnUnmount && entry.owned && entry.listeners.size === 0) {
|
|
76
|
+
entry.element.remove();
|
|
77
|
+
if (scripts.get(src) === entry) {
|
|
78
|
+
scripts.delete(src);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (domStatus) {
|
|
79
|
-
if (domStatus === "ready" || domStatus === "error") {
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const observer = observeScript(script);
|
|
83
|
-
return () => observer.disconnect();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
setStatus("unknown");
|
|
87
|
-
}, [src, options.removeOnUnmount]);
|
|
82
|
+
},
|
|
83
|
+
[src, removeOnUnmount],
|
|
84
|
+
);
|
|
88
85
|
|
|
89
|
-
return
|
|
86
|
+
return useSyncExternalStore(subscribe, getSnapshot);
|
|
90
87
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { type SetStateAction, useCallback, useReducer } from "react";
|
|
3
|
+
|
|
4
|
+
interface KeyedState<T> {
|
|
5
|
+
key: unknown;
|
|
6
|
+
value: T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type KeyedAction<T> =
|
|
10
|
+
| { type: "reset"; state: KeyedState<T> }
|
|
11
|
+
| { type: "update"; key: unknown; update: SetStateAction<T> };
|
|
12
|
+
|
|
13
|
+
function reduceKeyedState<T>(
|
|
14
|
+
previous: KeyedState<T>,
|
|
15
|
+
action: KeyedAction<T>,
|
|
16
|
+
): KeyedState<T> {
|
|
17
|
+
if (action.type === "reset") {
|
|
18
|
+
return action.state;
|
|
19
|
+
}
|
|
20
|
+
if (!Object.is(previous.key, action.key)) {
|
|
21
|
+
return previous;
|
|
22
|
+
}
|
|
23
|
+
const value =
|
|
24
|
+
typeof action.update === "function"
|
|
25
|
+
? (action.update as (previous: T) => T)(previous.value)
|
|
26
|
+
: action.update;
|
|
27
|
+
return Object.is(previous.value, value)
|
|
28
|
+
? previous
|
|
29
|
+
: { key: action.key, value };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Local state that resets when its owner key changes.
|
|
34
|
+
*
|
|
35
|
+
* The key uses Object.is. NaN matches NaN, 0 differs from -0, and objects match
|
|
36
|
+
* by reference. Keep the key stable between resets. An inline object such as
|
|
37
|
+
* `{ id }` creates a new key on each render and causes a render loop.
|
|
38
|
+
*
|
|
39
|
+
* On a key change, this hook calls initialize() and schedules a state update
|
|
40
|
+
* during render. React retries the calling component before rendering its
|
|
41
|
+
* children. Children and effects receive the reset value without an intervening
|
|
42
|
+
* commit of the old value. This resets only this hook's state. The component
|
|
43
|
+
* stays mounted, and its other hooks keep their state.
|
|
44
|
+
*
|
|
45
|
+
* initialize must be pure. React can call it more than once in Strict Mode or
|
|
46
|
+
* when retrying an interrupted render. A new initializer alone does not reset
|
|
47
|
+
* state. The next key change uses the initializer from that render. After
|
|
48
|
+
* committed key changes A -> B -> A, A starts fresh instead of restoring its
|
|
49
|
+
* earlier value. If React abandons a pending render for B, the visible state
|
|
50
|
+
* for A remains intact, including edits made while B was pending.
|
|
51
|
+
*
|
|
52
|
+
* The setter accepts values or pure updater functions, like useState. To store
|
|
53
|
+
* a function, use setValue(() => callback). Its identity is stable for the same
|
|
54
|
+
* key and changes with the key. Updates from a different key are ignored,
|
|
55
|
+
* including their updater functions. After A -> B -> A, an old setter for A
|
|
56
|
+
* can update A again. This hook does not cancel requests. For request results,
|
|
57
|
+
* use a distinct key for each lifetime so late results stay invalid.
|
|
58
|
+
*
|
|
59
|
+
* React can still call the component for ignored or same-value updates.
|
|
60
|
+
* Render counts do not indicate how many changes React commits to the screen.
|
|
61
|
+
*
|
|
62
|
+
* Use useInternalStateWithSync when the external value itself supplies the
|
|
63
|
+
* reset value. Use this hook when a separate key controls the reset.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const [expanded, setExpanded] = useStateForKey(tree, () => new Set<string>());
|
|
67
|
+
*
|
|
68
|
+
* @see https://react.dev/reference/react/useState#storing-information-from-previous-renders
|
|
69
|
+
*/
|
|
70
|
+
export function useStateForKey<T>(key: unknown, initialize: () => T) {
|
|
71
|
+
// useState can eagerly evaluate an updater against a suspended render's
|
|
72
|
+
// state. A reducer checks the key when React processes each render's queue,
|
|
73
|
+
// so the still-visible owner can accept edits during a transition.
|
|
74
|
+
const [stored, dispatch] = useReducer(reduceKeyedState<T>, key, (key) => ({
|
|
75
|
+
key,
|
|
76
|
+
value: initialize(),
|
|
77
|
+
}));
|
|
78
|
+
const current = Object.is(stored.key, key)
|
|
79
|
+
? stored
|
|
80
|
+
: { key, value: initialize() };
|
|
81
|
+
if (current !== stored) {
|
|
82
|
+
dispatch({ type: "reset", state: current });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const setValue = useCallback(
|
|
86
|
+
(update: SetStateAction<T>) => {
|
|
87
|
+
dispatch({ type: "update", key, update });
|
|
88
|
+
},
|
|
89
|
+
[key],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return [current.value, setValue] as const;
|
|
93
|
+
}
|
|
@@ -546,10 +546,12 @@ export const LoadingDataTableComponent = memo(
|
|
|
546
546
|
}
|
|
547
547
|
}, [setValue, filters, searchQuery, sorting, props.hasStableRowId]);
|
|
548
548
|
|
|
549
|
-
|
|
550
|
-
|
|
549
|
+
const [previousPageSize, setPreviousPageSize] = useState(props.pageSize);
|
|
550
|
+
// Reconcile before requesting data for the updated table configuration.
|
|
551
|
+
if (previousPageSize !== props.pageSize) {
|
|
552
|
+
setPreviousPageSize(props.pageSize);
|
|
551
553
|
setPaginationState({ pageIndex: 0, pageSize: props.pageSize });
|
|
552
|
-
}
|
|
554
|
+
}
|
|
553
555
|
|
|
554
556
|
// Data loading
|
|
555
557
|
const { data, error, isPending, isFetching } = useAsyncData<{
|
|
@@ -697,12 +699,13 @@ export const LoadingDataTableComponent = memo(
|
|
|
697
699
|
[search, sorting, filters, searchQuery],
|
|
698
700
|
);
|
|
699
701
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
+
const [previousTotalRows, setPreviousTotalRows] = useState(data?.totalRows);
|
|
703
|
+
if (previousTotalRows !== data?.totalRows) {
|
|
704
|
+
setPreviousTotalRows(data?.totalRows);
|
|
702
705
|
setPaginationState((state) =>
|
|
703
706
|
state.pageIndex === 0 ? state : { ...state, pageIndex: 0 },
|
|
704
707
|
);
|
|
705
|
-
}
|
|
708
|
+
}
|
|
706
709
|
|
|
707
710
|
// Column summaries
|
|
708
711
|
const { data: columnSummaries, error: columnSummariesError } = useAsyncData<
|
|
@@ -835,7 +838,7 @@ const DataTableComponent = ({
|
|
|
835
838
|
sizeBytesIsLoading,
|
|
836
839
|
maxColumns,
|
|
837
840
|
pagination,
|
|
838
|
-
selection,
|
|
841
|
+
selection: requestedSelection,
|
|
839
842
|
value,
|
|
840
843
|
showFilters,
|
|
841
844
|
showDownload,
|
|
@@ -843,7 +846,7 @@ const DataTableComponent = ({
|
|
|
843
846
|
showColumnExplorer,
|
|
844
847
|
showRowExplorer,
|
|
845
848
|
showChartBuilder,
|
|
846
|
-
showDataTypes,
|
|
849
|
+
showDataTypes: requestedShowDataTypes,
|
|
847
850
|
rowHeaders,
|
|
848
851
|
fieldTypes,
|
|
849
852
|
paginationState,
|
|
@@ -975,15 +978,11 @@ const DataTableComponent = ({
|
|
|
975
978
|
const fractionDigitsByColumn = useDeepCompareMemoize(computedFractionDigits);
|
|
976
979
|
const shownColumns = memoizedClampedFieldTypes.length;
|
|
977
980
|
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
showDataTypes = false;
|
|
981
|
-
}
|
|
981
|
+
// Only show data types when they are explicitly supplied.
|
|
982
|
+
const showDataTypes = Boolean(fieldTypes && requestedShowDataTypes);
|
|
982
983
|
|
|
983
984
|
// Row/cell selection writes back to the kernel, absent in static exports.
|
|
984
|
-
|
|
985
|
-
selection = null;
|
|
986
|
-
}
|
|
985
|
+
const selection = isStatic ? null : requestedSelection;
|
|
987
986
|
|
|
988
987
|
const columns = useMemo(
|
|
989
988
|
() =>
|