@marimo-team/islands 0.19.3-dev39 → 0.19.3-dev41
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/main.js +3 -2
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +1 -0
- package/src/core/cells/cells.ts +2 -0
- package/src/core/export/__tests__/hooks.test.ts +504 -0
- package/src/core/export/hooks.ts +93 -4
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +8 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +2 -0
- package/src/core/wasm/bridge.ts +1 -0
- package/src/hooks/__tests__/useInterval.test.tsx +104 -0
- package/src/hooks/useInterval.ts +32 -6
package/dist/main.js
CHANGED
|
@@ -39981,7 +39981,7 @@ ${c.sqlString}
|
|
|
39981
39981
|
return (_a2 = r[e2]) == null ? void 0 : _a2.name;
|
|
39982
39982
|
}).filter(Boolean);
|
|
39983
39983
|
};
|
|
39984
|
-
splitAtom(selectAtom(notebookAtom, (e) => e.cellIds.inOrderIds.map((r) => e.cellData[r]))), atom((e) => notebookIsRunning(e(notebookAtom))), atom((e) => notebookQueueOrRunningCount(e(notebookAtom))), atom((e) => e(notebookAtom).cellIds.colLength), atom((e) => e(notebookAtom).cellIds.idLength > 0), atom((e) => e(notebookAtom).cellIds.getColumnIds());
|
|
39984
|
+
splitAtom(selectAtom(notebookAtom, (e) => e.cellIds.inOrderIds.map((r) => e.cellData[r]))), atom((e) => e(notebookAtom).cellRuntime), atom((e) => notebookIsRunning(e(notebookAtom))), atom((e) => notebookQueueOrRunningCount(e(notebookAtom))), atom((e) => e(notebookAtom).cellIds.colLength), atom((e) => e(notebookAtom).cellIds.idLength > 0), atom((e) => e(notebookAtom).cellIds.getColumnIds());
|
|
39985
39985
|
const cellDataAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellData[e]));
|
|
39986
39986
|
var cellRuntimeAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellRuntime[e]));
|
|
39987
39987
|
const cellHandleAtom = atomFamily((e) => atom((r) => r(notebookAtom).cellHandles[e]));
|
|
@@ -101153,7 +101153,7 @@ Defaulting to \`null\`.`;
|
|
|
101153
101153
|
return Logger.warn("Failed to get version from mount config"), null;
|
|
101154
101154
|
}
|
|
101155
101155
|
}
|
|
101156
|
-
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.3-
|
|
101156
|
+
const marimoVersionAtom = atom(getVersionFromMountConfig() || "0.19.3-dev41"), showCodeInRunModeAtom = atom(true);
|
|
101157
101157
|
atom(null);
|
|
101158
101158
|
var VIRTUAL_FILE_REGEX = /\/@file\/([^\s"&'/]+)\.([\dA-Za-z]+)/g, VirtualFileTracker = class e {
|
|
101159
101159
|
constructor() {
|
|
@@ -102286,6 +102286,7 @@ ${r}
|
|
|
102286
102286
|
__publicField(this, "autoExportAsHTML", throwNotImplemented);
|
|
102287
102287
|
__publicField(this, "autoExportAsMarkdown", throwNotImplemented);
|
|
102288
102288
|
__publicField(this, "autoExportAsIPYNB", throwNotImplemented);
|
|
102289
|
+
__publicField(this, "updateCellOutputs", throwNotImplemented);
|
|
102289
102290
|
__publicField(this, "addPackage", throwNotImplemented);
|
|
102290
102291
|
__publicField(this, "removePackage", throwNotImplemented);
|
|
102291
102292
|
__publicField(this, "getPackageList", throwNotImplemented);
|
package/package.json
CHANGED
|
@@ -63,6 +63,7 @@ export const MockRequestClient = {
|
|
|
63
63
|
autoExportAsHTML: vi.fn().mockResolvedValue({}),
|
|
64
64
|
autoExportAsMarkdown: vi.fn().mockResolvedValue({}),
|
|
65
65
|
autoExportAsIPYNB: vi.fn().mockResolvedValue({}),
|
|
66
|
+
updateCellOutputs: vi.fn().mockResolvedValue({}),
|
|
66
67
|
addPackage: vi.fn().mockResolvedValue({}),
|
|
67
68
|
removePackage: vi.fn().mockResolvedValue({}),
|
|
68
69
|
getPackageList: vi.fn().mockResolvedValue({ packages: [] }),
|
package/src/core/cells/cells.ts
CHANGED
|
@@ -1594,6 +1594,8 @@ const cellDataAtoms = splitAtom(
|
|
|
1594
1594
|
);
|
|
1595
1595
|
export const useCellDataAtoms = () => useAtom(cellDataAtoms);
|
|
1596
1596
|
|
|
1597
|
+
export const cellsRuntimeAtom = atom((get) => get(notebookAtom).cellRuntime);
|
|
1598
|
+
|
|
1597
1599
|
export const notebookIsRunningAtom = atom((get) =>
|
|
1598
1600
|
notebookIsRunning(get(notebookAtom)),
|
|
1599
1601
|
);
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { CellId } from "@/core/cells/ids";
|
|
9
|
+
import { CellOutputId } from "@/core/cells/ids";
|
|
10
|
+
import type { CellRuntimeState } from "@/core/cells/types";
|
|
11
|
+
import { useEnrichCellOutputs } from "../hooks";
|
|
12
|
+
|
|
13
|
+
// Mock html-to-image
|
|
14
|
+
vi.mock("html-to-image", () => ({
|
|
15
|
+
toPng: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock Logger
|
|
19
|
+
vi.mock("@/utils/Logger", () => ({
|
|
20
|
+
Logger: {
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Mock cellsRuntimeAtom - must be defined inline in the factory function
|
|
26
|
+
vi.mock("@/core/cells/cells", async () => {
|
|
27
|
+
const { atom } = await import("jotai");
|
|
28
|
+
return {
|
|
29
|
+
cellsRuntimeAtom: atom({}),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
import { toPng } from "html-to-image";
|
|
34
|
+
import { cellsRuntimeAtom } from "@/core/cells/cells";
|
|
35
|
+
import { Logger } from "@/utils/Logger";
|
|
36
|
+
|
|
37
|
+
describe("useEnrichCellOutputs", () => {
|
|
38
|
+
let store: ReturnType<typeof createStore>;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
store = createStore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
46
|
+
React.createElement(Provider, { store }, children);
|
|
47
|
+
|
|
48
|
+
// Helper to set the mocked atom (cast to any to work around type mismatch)
|
|
49
|
+
const setCellsRuntime = (value: Record<CellId, CellRuntimeState>) => {
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
store.set(cellsRuntimeAtom as any, value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const createMockCellRuntimes = (
|
|
55
|
+
cells: Record<string, Partial<CellRuntimeState>>,
|
|
56
|
+
): Record<CellId, CellRuntimeState> => {
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(cells).map(([cellId, cell]) => [
|
|
59
|
+
cellId as CellId,
|
|
60
|
+
{
|
|
61
|
+
output: cell.output || null,
|
|
62
|
+
status: cell.status || "idle",
|
|
63
|
+
interrupted: false,
|
|
64
|
+
errored: false,
|
|
65
|
+
runStartTimestamp: null,
|
|
66
|
+
runElapsedTimeMs: null,
|
|
67
|
+
stallTime: null as unknown as number,
|
|
68
|
+
...cell,
|
|
69
|
+
} as CellRuntimeState,
|
|
70
|
+
]),
|
|
71
|
+
) as Record<CellId, CellRuntimeState>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
it("should return empty object when no cells need screenshots", async () => {
|
|
75
|
+
vi.spyOn(document, "getElementById");
|
|
76
|
+
|
|
77
|
+
// Set up cell runtimes with no text/html outputs
|
|
78
|
+
setCellsRuntime(
|
|
79
|
+
createMockCellRuntimes({
|
|
80
|
+
"cell-1": {
|
|
81
|
+
output: {
|
|
82
|
+
channel: "output",
|
|
83
|
+
mimetype: "text/plain",
|
|
84
|
+
data: "Hello World",
|
|
85
|
+
timestamp: 0,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
92
|
+
|
|
93
|
+
const enrichCellOutputs = result.current;
|
|
94
|
+
const output = await enrichCellOutputs();
|
|
95
|
+
|
|
96
|
+
expect(output).toEqual({});
|
|
97
|
+
expect(document.getElementById).not.toHaveBeenCalled();
|
|
98
|
+
expect(toPng).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should capture screenshots for cells with text/html output", async () => {
|
|
102
|
+
const cellId = "cell-1" as CellId;
|
|
103
|
+
const mockElement = document.createElement("div");
|
|
104
|
+
const mockDataUrl = "data:image/png;base64,mockImageData";
|
|
105
|
+
|
|
106
|
+
// Mock document.getElementById
|
|
107
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
108
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
109
|
+
|
|
110
|
+
setCellsRuntime(
|
|
111
|
+
createMockCellRuntimes({
|
|
112
|
+
[cellId]: {
|
|
113
|
+
output: {
|
|
114
|
+
channel: "output",
|
|
115
|
+
mimetype: "text/html",
|
|
116
|
+
data: "<div>Chart</div>",
|
|
117
|
+
timestamp: 0,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
124
|
+
|
|
125
|
+
const enrichCellOutputs = result.current;
|
|
126
|
+
const output = await enrichCellOutputs();
|
|
127
|
+
|
|
128
|
+
expect(document.getElementById).toHaveBeenCalledWith(
|
|
129
|
+
CellOutputId.create(cellId),
|
|
130
|
+
);
|
|
131
|
+
expect(toPng).toHaveBeenCalledWith(mockElement);
|
|
132
|
+
expect(output).toEqual({
|
|
133
|
+
[cellId]: ["image/png", mockDataUrl],
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should skip cells where output has not changed", async () => {
|
|
138
|
+
const cellId = "cell-1" as CellId;
|
|
139
|
+
const mockElement = document.createElement("div");
|
|
140
|
+
const mockDataUrl = "data:image/png;base64,mockImageData";
|
|
141
|
+
const htmlData = "<div>Chart</div>";
|
|
142
|
+
|
|
143
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
144
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
145
|
+
|
|
146
|
+
setCellsRuntime(
|
|
147
|
+
createMockCellRuntimes({
|
|
148
|
+
[cellId]: {
|
|
149
|
+
output: {
|
|
150
|
+
channel: "output",
|
|
151
|
+
mimetype: "text/html",
|
|
152
|
+
data: htmlData,
|
|
153
|
+
timestamp: 0,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
|
|
160
|
+
wrapper,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// First call - should capture
|
|
164
|
+
let enrichCellOutputs = result.current;
|
|
165
|
+
let output = await enrichCellOutputs();
|
|
166
|
+
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
|
|
167
|
+
expect(toPng).toHaveBeenCalledTimes(1);
|
|
168
|
+
|
|
169
|
+
// Rerender to get updated atom state
|
|
170
|
+
rerender();
|
|
171
|
+
|
|
172
|
+
// Second call with same output - should not capture again
|
|
173
|
+
enrichCellOutputs = result.current;
|
|
174
|
+
output = await enrichCellOutputs();
|
|
175
|
+
expect(output).toEqual({}); // Empty because output hasn't changed
|
|
176
|
+
expect(toPng).toHaveBeenCalledTimes(1); // Still only 1 call
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should handle screenshot errors gracefully", async () => {
|
|
180
|
+
const cellId = "cell-1" as CellId;
|
|
181
|
+
const mockElement = document.createElement("div");
|
|
182
|
+
const error = new Error("Screenshot failed");
|
|
183
|
+
|
|
184
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
185
|
+
vi.mocked(toPng).mockRejectedValue(error);
|
|
186
|
+
|
|
187
|
+
setCellsRuntime(
|
|
188
|
+
createMockCellRuntimes({
|
|
189
|
+
[cellId]: {
|
|
190
|
+
output: {
|
|
191
|
+
channel: "output",
|
|
192
|
+
mimetype: "text/html",
|
|
193
|
+
data: "<div>Chart</div>",
|
|
194
|
+
timestamp: 0,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
201
|
+
|
|
202
|
+
const enrichCellOutputs = result.current;
|
|
203
|
+
const output = await enrichCellOutputs();
|
|
204
|
+
|
|
205
|
+
expect(output).toEqual({}); // Failed screenshot should be filtered out
|
|
206
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
207
|
+
`Error screenshotting cell ${cellId}:`,
|
|
208
|
+
error,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should handle missing DOM elements", async () => {
|
|
213
|
+
const cellId = "cell-1" as CellId;
|
|
214
|
+
|
|
215
|
+
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
|
216
|
+
|
|
217
|
+
setCellsRuntime(
|
|
218
|
+
createMockCellRuntimes({
|
|
219
|
+
[cellId]: {
|
|
220
|
+
output: {
|
|
221
|
+
channel: "output",
|
|
222
|
+
mimetype: "text/html",
|
|
223
|
+
data: "<div>Chart</div>",
|
|
224
|
+
timestamp: 0,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
231
|
+
|
|
232
|
+
const enrichCellOutputs = result.current;
|
|
233
|
+
const output = await enrichCellOutputs();
|
|
234
|
+
|
|
235
|
+
expect(output).toEqual({});
|
|
236
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
237
|
+
`Output element not found for cell ${cellId}`,
|
|
238
|
+
);
|
|
239
|
+
expect(toPng).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should process multiple cells in parallel", async () => {
|
|
243
|
+
const cell1 = "cell-1" as CellId;
|
|
244
|
+
const cell2 = "cell-2" as CellId;
|
|
245
|
+
const mockElement1 = document.createElement("div");
|
|
246
|
+
const mockElement2 = document.createElement("div");
|
|
247
|
+
const mockDataUrl1 = "data:image/png;base64,image1";
|
|
248
|
+
const mockDataUrl2 = "data:image/png;base64,image2";
|
|
249
|
+
|
|
250
|
+
vi.spyOn(document, "getElementById")
|
|
251
|
+
.mockReturnValueOnce(mockElement1)
|
|
252
|
+
.mockReturnValueOnce(mockElement2);
|
|
253
|
+
|
|
254
|
+
vi.mocked(toPng)
|
|
255
|
+
.mockResolvedValueOnce(mockDataUrl1)
|
|
256
|
+
.mockResolvedValueOnce(mockDataUrl2);
|
|
257
|
+
|
|
258
|
+
setCellsRuntime(
|
|
259
|
+
createMockCellRuntimes({
|
|
260
|
+
[cell1]: {
|
|
261
|
+
output: {
|
|
262
|
+
channel: "output",
|
|
263
|
+
mimetype: "text/html",
|
|
264
|
+
data: "<div>Chart 1</div>",
|
|
265
|
+
timestamp: 0,
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
[cell2]: {
|
|
269
|
+
output: {
|
|
270
|
+
channel: "output",
|
|
271
|
+
mimetype: "text/html",
|
|
272
|
+
data: "<div>Chart 2</div>",
|
|
273
|
+
timestamp: 0,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
280
|
+
|
|
281
|
+
const enrichCellOutputs = result.current;
|
|
282
|
+
const output = await enrichCellOutputs();
|
|
283
|
+
|
|
284
|
+
expect(output).toEqual({
|
|
285
|
+
[cell1]: ["image/png", mockDataUrl1],
|
|
286
|
+
[cell2]: ["image/png", mockDataUrl2],
|
|
287
|
+
});
|
|
288
|
+
expect(toPng).toHaveBeenCalledTimes(2);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should filter out null results from failed screenshots", async () => {
|
|
292
|
+
// Setup: one successful, one failed screenshot
|
|
293
|
+
const cell1 = "cell-1" as CellId;
|
|
294
|
+
const cell2 = "cell-2" as CellId;
|
|
295
|
+
const mockElement1 = document.createElement("div");
|
|
296
|
+
const mockDataUrl = "data:image/png;base64,image1";
|
|
297
|
+
|
|
298
|
+
vi.spyOn(document, "getElementById")
|
|
299
|
+
.mockReturnValueOnce(mockElement1)
|
|
300
|
+
.mockReturnValueOnce(null); // Second cell fails to find element
|
|
301
|
+
|
|
302
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
303
|
+
|
|
304
|
+
setCellsRuntime(
|
|
305
|
+
createMockCellRuntimes({
|
|
306
|
+
[cell1]: {
|
|
307
|
+
output: {
|
|
308
|
+
channel: "output",
|
|
309
|
+
mimetype: "text/html",
|
|
310
|
+
data: "<div>Chart 1</div>",
|
|
311
|
+
timestamp: 0,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
[cell2]: {
|
|
315
|
+
output: {
|
|
316
|
+
channel: "output",
|
|
317
|
+
mimetype: "text/html",
|
|
318
|
+
data: "<div>Chart 2</div>",
|
|
319
|
+
timestamp: 0,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
326
|
+
|
|
327
|
+
const enrichCellOutputs = result.current;
|
|
328
|
+
const output = await enrichCellOutputs();
|
|
329
|
+
|
|
330
|
+
// Only the successful screenshot should be in the result
|
|
331
|
+
expect(output).toEqual({
|
|
332
|
+
[cell1]: ["image/png", mockDataUrl],
|
|
333
|
+
});
|
|
334
|
+
expect(Logger.error).toHaveBeenCalledWith(
|
|
335
|
+
`Output element not found for cell ${cell2}`,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should only capture screenshots for cells with changed output", async () => {
|
|
340
|
+
const cellId = "cell-1" as CellId;
|
|
341
|
+
const mockElement = document.createElement("div");
|
|
342
|
+
const mockDataUrl1 = "data:image/png;base64,image1";
|
|
343
|
+
const mockDataUrl2 = "data:image/png;base64,image2";
|
|
344
|
+
|
|
345
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
346
|
+
vi.mocked(toPng)
|
|
347
|
+
.mockResolvedValueOnce(mockDataUrl1)
|
|
348
|
+
.mockResolvedValueOnce(mockDataUrl2);
|
|
349
|
+
|
|
350
|
+
// First call - cell should be captured
|
|
351
|
+
setCellsRuntime(
|
|
352
|
+
createMockCellRuntimes({
|
|
353
|
+
[cellId]: {
|
|
354
|
+
output: {
|
|
355
|
+
channel: "output",
|
|
356
|
+
mimetype: "text/html",
|
|
357
|
+
data: "<div>Chart v1</div>",
|
|
358
|
+
timestamp: 0,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
|
|
365
|
+
wrapper,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// First screenshot
|
|
369
|
+
let enrichCellOutputs = result.current;
|
|
370
|
+
let output = await enrichCellOutputs();
|
|
371
|
+
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl1] });
|
|
372
|
+
|
|
373
|
+
// Second call - same output, should not be captured
|
|
374
|
+
rerender();
|
|
375
|
+
enrichCellOutputs = result.current;
|
|
376
|
+
output = await enrichCellOutputs();
|
|
377
|
+
expect(output).toEqual({});
|
|
378
|
+
|
|
379
|
+
// Third call - output changed, should be captured
|
|
380
|
+
setCellsRuntime(
|
|
381
|
+
createMockCellRuntimes({
|
|
382
|
+
[cellId]: {
|
|
383
|
+
output: {
|
|
384
|
+
channel: "output",
|
|
385
|
+
mimetype: "text/html",
|
|
386
|
+
data: "<div>Chart v2</div>", // Changed!
|
|
387
|
+
timestamp: 0,
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
rerender();
|
|
394
|
+
enrichCellOutputs = result.current;
|
|
395
|
+
output = await enrichCellOutputs();
|
|
396
|
+
expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl2] });
|
|
397
|
+
expect(toPng).toHaveBeenCalledTimes(2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("should ignore cells with non-text/html mimetype", async () => {
|
|
401
|
+
vi.spyOn(document, "getElementById");
|
|
402
|
+
|
|
403
|
+
setCellsRuntime(
|
|
404
|
+
createMockCellRuntimes({
|
|
405
|
+
"cell-1": {
|
|
406
|
+
output: {
|
|
407
|
+
channel: "output",
|
|
408
|
+
mimetype: "application/json",
|
|
409
|
+
data: '{"key": "value"}',
|
|
410
|
+
timestamp: 0,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
"cell-2": {
|
|
414
|
+
output: {
|
|
415
|
+
channel: "output",
|
|
416
|
+
mimetype: "text/plain",
|
|
417
|
+
data: "Plain text",
|
|
418
|
+
timestamp: 0,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
"cell-3": {
|
|
422
|
+
output: {
|
|
423
|
+
channel: "output",
|
|
424
|
+
mimetype: "image/png",
|
|
425
|
+
data: "data:image/png;base64,existing",
|
|
426
|
+
timestamp: 0,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
433
|
+
|
|
434
|
+
const enrichCellOutputs = result.current;
|
|
435
|
+
const output = await enrichCellOutputs();
|
|
436
|
+
|
|
437
|
+
// None of these should trigger screenshots
|
|
438
|
+
expect(output).toEqual({});
|
|
439
|
+
expect(document.getElementById).not.toHaveBeenCalled();
|
|
440
|
+
expect(toPng).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("should ignore cells with null or undefined output", async () => {
|
|
444
|
+
vi.spyOn(document, "getElementById");
|
|
445
|
+
|
|
446
|
+
setCellsRuntime(
|
|
447
|
+
createMockCellRuntimes({
|
|
448
|
+
"cell-1": {
|
|
449
|
+
output: null,
|
|
450
|
+
},
|
|
451
|
+
"cell-2": {
|
|
452
|
+
output: undefined,
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
458
|
+
|
|
459
|
+
const enrichCellOutputs = result.current;
|
|
460
|
+
const output = await enrichCellOutputs();
|
|
461
|
+
|
|
462
|
+
expect(output).toEqual({});
|
|
463
|
+
expect(document.getElementById).not.toHaveBeenCalled();
|
|
464
|
+
expect(toPng).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("should return correctly formatted result with CellId and tuple", async () => {
|
|
468
|
+
// Expected format: Record<CellId, ["image/png", string]>
|
|
469
|
+
const cellId = "test-cell" as CellId;
|
|
470
|
+
const mockElement = document.createElement("div");
|
|
471
|
+
const mockDataUrl = "data:image/png;base64,testData";
|
|
472
|
+
|
|
473
|
+
vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
|
|
474
|
+
vi.mocked(toPng).mockResolvedValue(mockDataUrl);
|
|
475
|
+
|
|
476
|
+
setCellsRuntime(
|
|
477
|
+
createMockCellRuntimes({
|
|
478
|
+
[cellId]: {
|
|
479
|
+
output: {
|
|
480
|
+
channel: "output",
|
|
481
|
+
mimetype: "text/html",
|
|
482
|
+
data: "<div>Content</div>",
|
|
483
|
+
timestamp: 0,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
|
|
490
|
+
|
|
491
|
+
const enrichCellOutputs = result.current;
|
|
492
|
+
const output = await enrichCellOutputs();
|
|
493
|
+
|
|
494
|
+
// Verify the exact return type structure
|
|
495
|
+
expect(output).toHaveProperty(cellId);
|
|
496
|
+
const cellOutput = output[cellId];
|
|
497
|
+
expect(cellOutput).toBeDefined();
|
|
498
|
+
expect(Array.isArray(cellOutput)).toBe(true);
|
|
499
|
+
if (cellOutput) {
|
|
500
|
+
expect(cellOutput[0]).toBe("image/png");
|
|
501
|
+
expect(cellOutput[1]).toBe(mockDataUrl);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import { toPng } from "html-to-image";
|
|
3
|
+
import { atom, useAtom, useAtomValue } from "jotai";
|
|
3
4
|
import { appConfigAtom } from "@/core/config/config";
|
|
4
5
|
import { useInterval } from "@/hooks/useInterval";
|
|
6
|
+
import { Logger } from "@/utils/Logger";
|
|
7
|
+
import { Objects } from "@/utils/objects";
|
|
8
|
+
import { cellsRuntimeAtom } from "../cells/cells";
|
|
9
|
+
import { type CellId, CellOutputId } from "../cells/ids";
|
|
5
10
|
import { connectionAtom } from "../network/connection";
|
|
6
11
|
import { useRequestClient } from "../network/requests";
|
|
7
12
|
import { VirtualFileTracker } from "../static/virtual-file-tracker";
|
|
@@ -21,8 +26,13 @@ export function useAutoExport() {
|
|
|
21
26
|
const markdownDisabled = !markdownEnabled || !isConnected;
|
|
22
27
|
const htmlDisabled = !htmlEnabled || !isConnected;
|
|
23
28
|
const ipynbDisabled = !ipynbEnabled || !isConnected;
|
|
24
|
-
const {
|
|
25
|
-
|
|
29
|
+
const {
|
|
30
|
+
autoExportAsHTML,
|
|
31
|
+
autoExportAsIPYNB,
|
|
32
|
+
autoExportAsMarkdown,
|
|
33
|
+
updateCellOutputs,
|
|
34
|
+
} = useRequestClient();
|
|
35
|
+
const takeScreenshots = useEnrichCellOutputs();
|
|
26
36
|
|
|
27
37
|
useInterval(
|
|
28
38
|
async () => {
|
|
@@ -50,12 +60,91 @@ export function useAutoExport() {
|
|
|
50
60
|
|
|
51
61
|
useInterval(
|
|
52
62
|
async () => {
|
|
63
|
+
const cellsToOutput = await takeScreenshots();
|
|
64
|
+
if (Object.keys(cellsToOutput).length > 0) {
|
|
65
|
+
await updateCellOutputs({
|
|
66
|
+
cellIdsToOutput: cellsToOutput,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
53
69
|
await autoExportAsIPYNB({
|
|
54
70
|
download: false,
|
|
55
71
|
});
|
|
56
72
|
},
|
|
57
73
|
// Run every 5 seconds, or when the document becomes visible
|
|
58
74
|
// Ignore if the document is not visible
|
|
59
|
-
|
|
75
|
+
// Skip if running to ensure no race conditions between screenshot and export
|
|
76
|
+
{
|
|
77
|
+
delayMs: DELAY,
|
|
78
|
+
whenVisible: true,
|
|
79
|
+
disabled: ipynbDisabled,
|
|
80
|
+
skipIfRunning: true,
|
|
81
|
+
},
|
|
60
82
|
);
|
|
61
83
|
}
|
|
84
|
+
|
|
85
|
+
// We track cells that need screenshots, these will be exported to IPYNB
|
|
86
|
+
const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
|
|
90
|
+
* @returns A map of cell IDs to their screenshots data.
|
|
91
|
+
*/
|
|
92
|
+
export function useEnrichCellOutputs() {
|
|
93
|
+
const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
|
|
94
|
+
const cellRuntimes = useAtomValue(cellsRuntimeAtom);
|
|
95
|
+
|
|
96
|
+
return async (): Promise<Record<CellId, ["image/png", string]>> => {
|
|
97
|
+
const trackedCellsOutput: Record<CellId, unknown> = {};
|
|
98
|
+
|
|
99
|
+
const cellsToCaptureScreenshot: [CellId, unknown][] = [];
|
|
100
|
+
for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
|
|
101
|
+
const outputData = runtime.output?.data;
|
|
102
|
+
const outputHasChanged = richCellsOutput[cellId] !== outputData;
|
|
103
|
+
// Track latest output for this cell
|
|
104
|
+
trackedCellsOutput[cellId] = outputData;
|
|
105
|
+
if (
|
|
106
|
+
runtime.output?.mimetype === "text/html" &&
|
|
107
|
+
outputData &&
|
|
108
|
+
outputHasChanged
|
|
109
|
+
) {
|
|
110
|
+
cellsToCaptureScreenshot.push([cellId, runtime]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Always update tracked outputs, this ensures data is fresh for the next run
|
|
114
|
+
setRichCellsOutput(trackedCellsOutput);
|
|
115
|
+
|
|
116
|
+
if (cellsToCaptureScreenshot.length === 0) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Capture screenshots
|
|
121
|
+
const results = await Promise.all(
|
|
122
|
+
cellsToCaptureScreenshot.map(async ([cellId]) => {
|
|
123
|
+
const outputElement = document.getElementById(
|
|
124
|
+
CellOutputId.create(cellId),
|
|
125
|
+
);
|
|
126
|
+
if (!outputElement) {
|
|
127
|
+
Logger.error(`Output element not found for cell ${cellId}`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const dataUrl = await toPng(outputElement);
|
|
133
|
+
return [cellId, ["image/png", dataUrl]] as [
|
|
134
|
+
CellId,
|
|
135
|
+
["image/png", string],
|
|
136
|
+
];
|
|
137
|
+
} catch (error) {
|
|
138
|
+
Logger.error(`Error screenshotting cell ${cellId}:`, error);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return Objects.fromEntries(
|
|
145
|
+
results.filter(
|
|
146
|
+
(result): result is [CellId, ["image/png", string]] => result !== null,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -171,6 +171,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
171
171
|
autoExportAsHTML = throwNotImplemented;
|
|
172
172
|
autoExportAsMarkdown = throwNotImplemented;
|
|
173
173
|
autoExportAsIPYNB = throwNotImplemented;
|
|
174
|
+
updateCellOutputs = throwNotImplemented;
|
|
174
175
|
addPackage = throwNotImplemented;
|
|
175
176
|
removePackage = throwNotImplemented;
|
|
176
177
|
getPackageList = throwNotImplemented;
|
|
@@ -67,6 +67,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
|
|
|
67
67
|
autoExportAsHTML: "waitForConnectionOpen",
|
|
68
68
|
autoExportAsMarkdown: "waitForConnectionOpen",
|
|
69
69
|
autoExportAsIPYNB: "waitForConnectionOpen",
|
|
70
|
+
updateCellOutputs: "waitForConnectionOpen",
|
|
70
71
|
|
|
71
72
|
// Sidebar operations that wait for connection
|
|
72
73
|
listSecretKeys: "throwError",
|
|
@@ -405,6 +405,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
405
405
|
})
|
|
406
406
|
.then(handleResponseReturnNull);
|
|
407
407
|
},
|
|
408
|
+
updateCellOutputs: async (request) => {
|
|
409
|
+
return getClient()
|
|
410
|
+
.POST("/api/export/update_cell_outputs", {
|
|
411
|
+
body: request,
|
|
412
|
+
params: getParams(),
|
|
413
|
+
})
|
|
414
|
+
.then(handleResponseReturnNull);
|
|
415
|
+
},
|
|
408
416
|
addPackage: (request) => {
|
|
409
417
|
return getClient()
|
|
410
418
|
.POST("/api/packages/add", {
|
|
@@ -79,6 +79,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
79
79
|
autoExportAsHTML: throwNotInEditMode,
|
|
80
80
|
autoExportAsMarkdown: throwNotInEditMode,
|
|
81
81
|
autoExportAsIPYNB: throwNotInEditMode,
|
|
82
|
+
updateCellOutputs: throwNotInEditMode,
|
|
82
83
|
addPackage: throwNotInEditMode,
|
|
83
84
|
removePackage: throwNotInEditMode,
|
|
84
85
|
getPackageList: throwNotInEditMode,
|
|
@@ -64,6 +64,7 @@ export function createErrorToastingRequests(
|
|
|
64
64
|
autoExportAsHTML: "", // No toast
|
|
65
65
|
autoExportAsMarkdown: "", // No toast
|
|
66
66
|
autoExportAsIPYNB: "", // No toast
|
|
67
|
+
updateCellOutputs: "", // No toast
|
|
67
68
|
addPackage: "Failed to add package",
|
|
68
69
|
removePackage: "Failed to remove package",
|
|
69
70
|
getPackageList: "Failed to get package list",
|
|
@@ -21,6 +21,7 @@ export type ExportAsHTMLRequest = schemas["ExportAsHTMLRequest"];
|
|
|
21
21
|
export type ExportAsMarkdownRequest = schemas["ExportAsMarkdownRequest"];
|
|
22
22
|
export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
|
|
23
23
|
export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
|
|
24
|
+
export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
|
|
24
25
|
export type FileCreateRequest = schemas["FileCreateRequest"];
|
|
25
26
|
export type FileCreateResponse = schemas["FileCreateResponse"];
|
|
26
27
|
export type FileDeleteRequest = schemas["FileDeleteRequest"];
|
|
@@ -175,6 +176,7 @@ export interface EditRequests {
|
|
|
175
176
|
autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
|
|
176
177
|
autoExportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<null>;
|
|
177
178
|
autoExportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<null>;
|
|
179
|
+
updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>;
|
|
178
180
|
// Package requests
|
|
179
181
|
getPackageList: () => Promise<ListPackagesResponse>;
|
|
180
182
|
getDependencyTree: () => Promise<DependencyTreeResponse>;
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -589,6 +589,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
589
589
|
autoExportAsHTML = throwNotImplemented;
|
|
590
590
|
autoExportAsMarkdown = throwNotImplemented;
|
|
591
591
|
autoExportAsIPYNB = throwNotImplemented;
|
|
592
|
+
updateCellOutputs = throwNotImplemented;
|
|
592
593
|
writeSecret = throwNotImplemented;
|
|
593
594
|
invokeAiTool = throwNotImplemented;
|
|
594
595
|
clearCache = throwNotImplemented;
|
|
@@ -67,4 +67,108 @@ describe("useInterval", () => {
|
|
|
67
67
|
vi.advanceTimersByTime(1000);
|
|
68
68
|
expect(callback).not.toHaveBeenCalled();
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("skipIfRunning", () => {
|
|
72
|
+
it("should allow overlapping async calls by default", async () => {
|
|
73
|
+
let concurrentCalls = 0;
|
|
74
|
+
let maxConcurrentCalls = 0;
|
|
75
|
+
|
|
76
|
+
const callback = vi.fn(async () => {
|
|
77
|
+
concurrentCalls++;
|
|
78
|
+
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
|
79
|
+
// Simulate slow async work
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
81
|
+
concurrentCalls--;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
renderHook(() =>
|
|
85
|
+
useInterval(callback, { delayMs: 500, whenVisible: false }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// First call at 500ms
|
|
89
|
+
vi.advanceTimersByTime(500);
|
|
90
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
91
|
+
|
|
92
|
+
// Second call at 1000ms (while first is still running)
|
|
93
|
+
vi.advanceTimersByTime(500);
|
|
94
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
95
|
+
|
|
96
|
+
// Third call at 1500ms
|
|
97
|
+
vi.advanceTimersByTime(500);
|
|
98
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
99
|
+
|
|
100
|
+
// Multiple concurrent calls should have occurred
|
|
101
|
+
expect(maxConcurrentCalls).toBeGreaterThan(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should skip calls when skipIfRunning is true", async () => {
|
|
105
|
+
let concurrentCalls = 0;
|
|
106
|
+
let maxConcurrentCalls = 0;
|
|
107
|
+
|
|
108
|
+
const callback = vi.fn(async () => {
|
|
109
|
+
concurrentCalls++;
|
|
110
|
+
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
|
111
|
+
// Simulate slow async work (3 seconds)
|
|
112
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
|
113
|
+
concurrentCalls--;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
renderHook(() =>
|
|
117
|
+
useInterval(callback, {
|
|
118
|
+
delayMs: 500,
|
|
119
|
+
whenVisible: false,
|
|
120
|
+
skipIfRunning: true,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// First call at 500ms
|
|
125
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
126
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
127
|
+
|
|
128
|
+
// Second interval tick at 1000ms - should be skipped since first is still running
|
|
129
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
130
|
+
expect(callback).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
131
|
+
|
|
132
|
+
// Third interval tick at 1500ms - should still be skipped
|
|
133
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
134
|
+
expect(callback).toHaveBeenCalledTimes(1); // Still 1
|
|
135
|
+
|
|
136
|
+
// Only one concurrent call should have occurred
|
|
137
|
+
expect(maxConcurrentCalls).toBe(1);
|
|
138
|
+
|
|
139
|
+
// Advance past the 3 second timeout to complete first callback
|
|
140
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
141
|
+
|
|
142
|
+
// Next interval tick should now be able to run
|
|
143
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
144
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should allow next call after previous async call completes with skipIfRunning true", async () => {
|
|
148
|
+
const callback = vi.fn(async () => {
|
|
149
|
+
// Quick async operation
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
renderHook(() =>
|
|
154
|
+
useInterval(callback, {
|
|
155
|
+
delayMs: 1000,
|
|
156
|
+
whenVisible: false,
|
|
157
|
+
skipIfRunning: true,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// First call
|
|
162
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
163
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
164
|
+
|
|
165
|
+
// Second call - should proceed since first completed
|
|
166
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
167
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
168
|
+
|
|
169
|
+
// Third call
|
|
170
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
171
|
+
expect(callback).toHaveBeenCalledTimes(3);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
70
174
|
});
|
package/src/hooks/useInterval.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
3
3
|
import { useEventListener } from "./useEventListener";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Creates an interval that runs a callback every `delayMs` milliseconds.
|
|
7
|
+
*
|
|
8
|
+
* @param callback - The callback to run.
|
|
9
|
+
* @param opts - The options for the interval.
|
|
10
|
+
* @param opts.delayMs - The delay in milliseconds between runs.
|
|
11
|
+
* @param opts.whenVisible - Whether to run the callback when the document is visible.
|
|
12
|
+
* @param opts.disabled - Whether to disable the interval.
|
|
13
|
+
* @param opts.skipIfRunning - Whether to skip the callback if it is already running.
|
|
7
14
|
*/
|
|
8
15
|
export function useInterval(
|
|
9
16
|
callback: () => void,
|
|
@@ -11,16 +18,35 @@ export function useInterval(
|
|
|
11
18
|
delayMs: number | null;
|
|
12
19
|
whenVisible: boolean;
|
|
13
20
|
disabled?: boolean;
|
|
21
|
+
skipIfRunning?: boolean;
|
|
14
22
|
},
|
|
15
23
|
) {
|
|
16
|
-
const {
|
|
17
|
-
|
|
24
|
+
const {
|
|
25
|
+
delayMs,
|
|
26
|
+
whenVisible,
|
|
27
|
+
disabled = false,
|
|
28
|
+
skipIfRunning = false,
|
|
29
|
+
} = opts;
|
|
30
|
+
const savedCallback = useRef<() => void | Promise<void>>(undefined);
|
|
31
|
+
const isRunning = useRef(false);
|
|
18
32
|
|
|
19
33
|
// Store the callback
|
|
20
34
|
useEffect(() => {
|
|
21
35
|
savedCallback.current = callback;
|
|
22
36
|
}, [callback]);
|
|
23
37
|
|
|
38
|
+
const runCallback = useCallback(async () => {
|
|
39
|
+
if (isRunning.current && skipIfRunning) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
isRunning.current = true;
|
|
43
|
+
try {
|
|
44
|
+
await savedCallback.current?.();
|
|
45
|
+
} finally {
|
|
46
|
+
isRunning.current = false;
|
|
47
|
+
}
|
|
48
|
+
}, [skipIfRunning]);
|
|
49
|
+
|
|
24
50
|
// Run the interval
|
|
25
51
|
useEffect(() => {
|
|
26
52
|
if (delayMs === null || disabled) {
|
|
@@ -32,16 +58,16 @@ export function useInterval(
|
|
|
32
58
|
return;
|
|
33
59
|
}
|
|
34
60
|
|
|
35
|
-
|
|
61
|
+
runCallback();
|
|
36
62
|
}, delayMs);
|
|
37
63
|
|
|
38
64
|
return () => clearInterval(id);
|
|
39
|
-
}, [delayMs, whenVisible, disabled]);
|
|
65
|
+
}, [delayMs, whenVisible, disabled, runCallback]);
|
|
40
66
|
|
|
41
67
|
// When the document becomes visible, run the callback
|
|
42
68
|
useEventListener(document, "visibilitychange", () => {
|
|
43
69
|
if (document.visibilityState === "visible" && whenVisible && !disabled) {
|
|
44
|
-
|
|
70
|
+
runCallback();
|
|
45
71
|
}
|
|
46
72
|
});
|
|
47
73
|
|