@marimo-team/frontend 0.19.7-dev35 → 0.19.7-dev37
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/assets/{JsonOutput-BGSqWZkD.js → JsonOutput-DtidtKaJ.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-DFV7O8tN.js → MarimoErrorOutput-Cci2wITc.js} +1 -1
- package/dist/assets/{add-cell-with-ai-gDjJxct8.js → add-cell-with-ai-DT1ae2MK.js} +1 -1
- package/dist/assets/{add-database-form-CqEjMrq2.js → add-database-form-_pTLtiHN.js} +1 -1
- package/dist/assets/{agent-panel-D_hcgMlT.js → agent-panel-oLGqa4bG.js} +1 -1
- package/dist/assets/{ai-model-dropdown-C_l_zAXd.js → ai-model-dropdown-BL-PaF8o.js} +1 -1
- package/dist/assets/{app-config-button-CM05VD_Y.js → app-config-button-BiwYEPE8.js} +1 -1
- package/dist/assets/{cell-editor-ihhw9Ql3.js → cell-editor-B3U1SnYJ.js} +1 -1
- package/dist/assets/{chat-display-yGAtJXm0.js → chat-display-B34MaCGM.js} +1 -1
- package/dist/assets/{chat-panel-D3T1CjpZ.js → chat-panel-fuQFRvFm.js} +1 -1
- package/dist/assets/{column-preview-DbqM1diq.js → column-preview-B-dViv1i.js} +1 -1
- package/dist/assets/{command-palette-C2L80RlN.js → command-palette-KuNgJNix.js} +1 -1
- package/dist/assets/{common-D32S2RVD.js → common-DxKcMlJZ.js} +1 -1
- package/dist/assets/{dependency-graph-panel-C7ZXgECY.js → dependency-graph-panel-BZEIOxVz.js} +1 -1
- package/dist/assets/download-Bwa9P-Pz.js +6 -0
- package/dist/assets/{dropdown-menu-df9T83C0.js → dropdown-menu-B-6unW-7.js} +1 -1
- package/dist/assets/edit-page-DBFIML4p.js +13 -0
- package/dist/assets/{error-panel-DKIWwbhe.js → error-panel-DulelhA-.js} +1 -1
- package/dist/assets/{file-explorer-panel-D4VHkjMW.js → file-explorer-panel-Dn9tKw3E.js} +1 -1
- package/dist/assets/{form-XjMNGyzu.js → form-B1n-e_X0.js} +1 -1
- package/dist/assets/{glide-data-editor-BBSxoBI-.js → glide-data-editor-HGkaxqOo.js} +1 -1
- package/dist/assets/home-page--XVUAUCM.js +4 -0
- package/dist/assets/hooks-B1nUQK2T.js +1 -0
- package/dist/assets/{html-to-image-DKvXQkl5.js → html-to-image-Cu1p0tCK.js} +2 -2
- package/dist/assets/{index-DmMvDRRC.css → index-Bj5F80Z9.css} +1 -1
- package/dist/assets/{index-BRCNo5ma.js → index-DTpRqi46.js} +5 -5
- package/dist/assets/{layout-DiDoTAUA.js → layout-KY92f2Sm.js} +3 -3
- package/dist/assets/{markdown-renderer-xQJ1KM4c.js → markdown-renderer-Dpn5NCvn.js} +1 -1
- package/dist/assets/{packages-panel-D53RuG3X.js → packages-panel-CBc59eNR.js} +1 -1
- package/dist/assets/{panels-BhznEx5N.js → panels-B0B71dYl.js} +1 -1
- package/dist/assets/{popover-D16ZremR.js → popover-Gz-GJzym.js} +1 -1
- package/dist/assets/{readonly-python-code-kImQwJ5f.js → readonly-python-code-CCwpyiLX.js} +1 -1
- package/dist/assets/{renderShortcut-DHc-p-_c.js → renderShortcut-DEwfrKeS.js} +1 -1
- package/dist/assets/run-page-Dug0EU2T.js +1 -0
- package/dist/assets/{scratchpad-panel-C8wx6cRl.js → scratchpad-panel-SMFZ5eRQ.js} +1 -1
- package/dist/assets/{secrets-panel-Br6CcsOE.js → secrets-panel-BaEqnh6m.js} +1 -1
- package/dist/assets/{session-panel-JOOuJNOH.js → session-panel-CR_CZBSy.js} +1 -1
- package/dist/assets/table-C8uQmBAN.js +1 -0
- package/dist/assets/{terminal-DNwT6UrR.js → terminal-C7HXI-7B.js} +1 -1
- package/dist/assets/{tree-BdwmBGSx.js → tree-B1vM35Zj.js} +1 -1
- package/dist/assets/{useAddCell-BMDEXuVk.js → useAddCell-DRmuczCx.js} +1 -1
- package/dist/assets/{useCellActionButton-BDMlZzyv.js → useCellActionButton-DwRoApVS.js} +1 -1
- package/dist/assets/{useDeleteCell-BrMXAFkS.js → useDeleteCell-CR3IczUk.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-B63bb4YX.js → useDependencyPanelTab-CLgnO1zH.js} +1 -1
- package/dist/assets/useNotebookActions-DhF-uJ0P.js +1 -0
- package/dist/assets/{useSplitCell-Cb0lf5MV.js → useSplitCell-IQsKBoRj.js} +1 -1
- package/dist/assets/{utilities.esm-BVFPJPyV.js → utilities.esm-DyYLtC1k.js} +2 -2
- package/dist/index.html +20 -20
- package/package.json +1 -1
- package/src/components/data-table/TableActions.tsx +5 -3
- package/src/components/data-table/download-actions.tsx +7 -2
- package/src/components/data-table/pagination.tsx +4 -4
- package/src/components/debug/indicator.tsx +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +4 -2
- package/src/components/editor/chrome/panels/context-aware-panel/context-aware-panel.tsx +1 -1
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +6 -4
- package/src/components/editor/chrome/wrapper/footer-items/lsp-status.tsx +178 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +1 -1
- package/src/components/editor/chrome/wrapper/sidebar.tsx +1 -1
- package/src/components/editor/controls/Controls.tsx +2 -2
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +1 -1
- package/src/components/editor/file-tree/file-explorer.tsx +1 -1
- package/src/components/editor/header/status.tsx +1 -1
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +13 -4
- package/src/components/home/components.tsx +1 -1
- package/src/components/static-html/static-banner.tsx +1 -1
- package/src/components/ui/dropdown-menu.tsx +1 -1
- package/src/components/ui/table.tsx +1 -1
- package/src/core/export/__tests__/hooks.test.ts +60 -58
- package/src/core/export/hooks.ts +71 -31
- package/src/core/network/types.ts +4 -0
- package/src/css/app/print.css +0 -14
- package/src/utils/__tests__/async-capture-tracker.test.ts +353 -0
- package/src/utils/__tests__/download.test.tsx +5 -114
- package/src/utils/async-capture-tracker.ts +168 -0
- package/src/utils/download.ts +17 -57
- package/src/utils/html-to-image.ts +9 -12
- package/dist/assets/download-CzPV-R6Z.js +0 -6
- package/dist/assets/edit-page-DFpXAt-U.js +0 -12
- package/dist/assets/home-page-DBWXVxWa.js +0 -4
- package/dist/assets/hooks-CpjzmBkw.js +0 -1
- package/dist/assets/run-page-BCOAGhl5.js +0 -1
- package/dist/assets/table-BSASHvkq.js +0 -1
- package/dist/assets/useNotebookActions-Cj4FtiIb.js +0 -1
package/src/core/export/hooks.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import { useAtomValue } from "jotai";
|
|
3
3
|
import type { MimeType } from "@/components/editor/Output";
|
|
4
4
|
import { toast } from "@/components/ui/use-toast";
|
|
5
5
|
import { appConfigAtom } from "@/core/config/config";
|
|
6
6
|
import { useInterval } from "@/hooks/useInterval";
|
|
7
|
+
import { AsyncCaptureTracker } from "@/utils/async-capture-tracker";
|
|
7
8
|
import { getImageDataUrlForCell } from "@/utils/download";
|
|
8
9
|
import { Logger } from "@/utils/Logger";
|
|
9
10
|
import { Objects } from "@/utils/objects";
|
|
@@ -67,7 +68,6 @@ export function useAutoExport() {
|
|
|
67
68
|
const screenshotFn = () =>
|
|
68
69
|
takeScreenshots({
|
|
69
70
|
progress: ProgressState.indeterminate(),
|
|
70
|
-
snappy: true,
|
|
71
71
|
});
|
|
72
72
|
await updateCellOutputsWithScreenshots({
|
|
73
73
|
takeScreenshots: screenshotFn,
|
|
@@ -89,9 +89,6 @@ export function useAutoExport() {
|
|
|
89
89
|
);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
// We track cells that need screenshots, these will be exported to IPYNB
|
|
93
|
-
const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
|
|
94
|
-
|
|
95
92
|
// MIME types to capture screenshots for
|
|
96
93
|
const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
97
94
|
"text/html",
|
|
@@ -101,66 +98,109 @@ const MIME_TYPES_TO_CAPTURE_SCREENSHOTS = new Set<MimeType>([
|
|
|
101
98
|
"application/vnd.vega.v6+json",
|
|
102
99
|
]);
|
|
103
100
|
|
|
104
|
-
type
|
|
101
|
+
type ScreenshotResult = ["image/png", string];
|
|
102
|
+
type ScreenshotResults = Record<CellId, ScreenshotResult>;
|
|
103
|
+
|
|
104
|
+
// Only marks cells as captured after successful screenshot.
|
|
105
|
+
export const captureTracker = new AsyncCaptureTracker<
|
|
106
|
+
CellId,
|
|
107
|
+
ScreenshotResult
|
|
108
|
+
>();
|
|
109
|
+
|
|
110
|
+
interface UseEnrichCellOutputsOptions {
|
|
111
|
+
progress: ProgressState;
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
/**
|
|
107
115
|
* Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
|
|
108
116
|
* @returns A map of cell IDs to their screenshots data.
|
|
109
117
|
*/
|
|
110
|
-
export function useEnrichCellOutputs()
|
|
111
|
-
|
|
118
|
+
export function useEnrichCellOutputs(): (
|
|
119
|
+
opts: UseEnrichCellOutputsOptions,
|
|
120
|
+
) => Promise<ScreenshotResults> {
|
|
112
121
|
const cellRuntimes = useAtomValue(cellsRuntimeAtom);
|
|
113
122
|
|
|
114
|
-
return async (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
return async (
|
|
124
|
+
opts: UseEnrichCellOutputsOptions,
|
|
125
|
+
): Promise<ScreenshotResults> => {
|
|
126
|
+
const { progress } = opts;
|
|
127
|
+
|
|
128
|
+
// Prune tracked state for cells that no longer exist
|
|
129
|
+
const currentCellIds = new Set(Objects.keys(cellRuntimes));
|
|
130
|
+
captureTracker.prune(currentCellIds);
|
|
122
131
|
|
|
123
132
|
const cellsToCaptureScreenshot: [CellId, unknown][] = [];
|
|
133
|
+
const inFlightWaiters: {
|
|
134
|
+
cellId: CellId;
|
|
135
|
+
promise: Promise<ScreenshotResult | undefined>;
|
|
136
|
+
}[] = [];
|
|
137
|
+
|
|
124
138
|
for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
|
|
125
139
|
const outputData = runtime.output?.data;
|
|
126
|
-
const outputHasChanged = richCellsOutput[cellId] !== outputData;
|
|
127
|
-
// Track latest output for this cell
|
|
128
|
-
trackedCellsOutput[cellId] = outputData;
|
|
129
140
|
if (
|
|
130
141
|
runtime.output?.mimetype &&
|
|
131
142
|
MIME_TYPES_TO_CAPTURE_SCREENSHOTS.has(runtime.output.mimetype) &&
|
|
132
|
-
outputData
|
|
133
|
-
outputHasChanged
|
|
143
|
+
outputData
|
|
134
144
|
) {
|
|
135
|
-
|
|
145
|
+
if (captureTracker.needsCapture(cellId, outputData)) {
|
|
146
|
+
cellsToCaptureScreenshot.push([cellId, outputData]);
|
|
147
|
+
} else {
|
|
148
|
+
// If already in-flight with the same value, await its result
|
|
149
|
+
const promise = captureTracker.waitForInFlight(cellId, outputData);
|
|
150
|
+
if (promise) {
|
|
151
|
+
inFlightWaiters.push({ cellId, promise });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
|
-
// Always update tracked outputs, this ensures data is fresh for the next run
|
|
139
|
-
setRichCellsOutput(trackedCellsOutput);
|
|
140
156
|
|
|
141
|
-
if (cellsToCaptureScreenshot.length === 0) {
|
|
157
|
+
if (cellsToCaptureScreenshot.length === 0 && inFlightWaiters.length === 0) {
|
|
142
158
|
return {};
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
// Start the progress bar for new captures only
|
|
162
|
+
if (cellsToCaptureScreenshot.length > 0) {
|
|
163
|
+
progress.addTotal(cellsToCaptureScreenshot.length);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Capture screenshots — each key gets its own AbortSignal so
|
|
167
|
+
// aborting one cell does not affect the others.
|
|
148
168
|
const results: ScreenshotResults = {};
|
|
149
|
-
for (const [cellId] of cellsToCaptureScreenshot) {
|
|
169
|
+
for (const [cellId, outputData] of cellsToCaptureScreenshot) {
|
|
170
|
+
const handle = captureTracker.startCapture(cellId, outputData);
|
|
150
171
|
try {
|
|
151
|
-
const dataUrl = await getImageDataUrlForCell(cellId
|
|
172
|
+
const dataUrl = await getImageDataUrlForCell(cellId);
|
|
173
|
+
if (handle.signal.aborted) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
152
176
|
if (!dataUrl) {
|
|
153
177
|
Logger.error(`Failed to capture screenshot for cell ${cellId}`);
|
|
178
|
+
handle.markFailed();
|
|
154
179
|
continue;
|
|
155
180
|
}
|
|
156
|
-
|
|
181
|
+
const result: ScreenshotResult = ["image/png", dataUrl];
|
|
182
|
+
results[cellId] = result;
|
|
183
|
+
handle.markCaptured(result);
|
|
157
184
|
} catch (error) {
|
|
158
185
|
Logger.error(`Error screenshotting cell ${cellId}:`, error);
|
|
186
|
+
handle.markFailed();
|
|
159
187
|
} finally {
|
|
160
188
|
progress.increment(1);
|
|
161
189
|
}
|
|
162
190
|
}
|
|
163
191
|
|
|
192
|
+
// Await in-flight captures started by concurrent callers
|
|
193
|
+
const settled = await Promise.allSettled(
|
|
194
|
+
inFlightWaiters.map(({ promise }) => promise),
|
|
195
|
+
);
|
|
196
|
+
for (const [i, { cellId }] of inFlightWaiters.entries()) {
|
|
197
|
+
const result =
|
|
198
|
+
settled[i].status === "fulfilled" ? settled[i].value : undefined;
|
|
199
|
+
if (result) {
|
|
200
|
+
results[cellId] = result;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
164
204
|
return results;
|
|
165
205
|
};
|
|
166
206
|
}
|
|
@@ -100,6 +100,10 @@ export type InvokeAiToolRequest = schemas["InvokeAiToolRequest"];
|
|
|
100
100
|
export type InvokeAiToolResponse = schemas["InvokeAiToolResponse"];
|
|
101
101
|
export type ClearCacheRequest = schemas["ClearCacheRequest"];
|
|
102
102
|
export type GetCacheInfoRequest = schemas["GetCacheInfoRequest"];
|
|
103
|
+
export type LspHealthResponse = schemas["LspHealthResponse"];
|
|
104
|
+
export type LspRestartRequest = schemas["LspRestartRequest"];
|
|
105
|
+
export type LspRestartResponse = schemas["LspRestartResponse"];
|
|
106
|
+
export type LspServerHealth = schemas["LspServerHealth"];
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* Requests sent to the BE during run/edit mode.
|
package/src/css/app/print.css
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
@reference "../globals.css";
|
|
2
2
|
|
|
3
|
-
/* Hide on print
|
|
4
|
-
To hide an element on print, add the class `no-print` to it.
|
|
5
|
-
When printing, the class `printing` is added to the body element to enable this rule. */
|
|
6
|
-
body.printing .no-print {
|
|
7
|
-
display: none !important;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
3
|
body.printing #App {
|
|
11
4
|
/* Full screen print */
|
|
12
5
|
height: fit-content !important;
|
|
13
6
|
}
|
|
14
7
|
|
|
15
|
-
/* When printing the output of a cell, this unset the max-height set by the notebook to capture the full output */
|
|
16
|
-
.printing-output {
|
|
17
|
-
max-height: none !important;
|
|
18
|
-
|
|
19
|
-
@apply bg-background;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
8
|
@media print {
|
|
23
9
|
* {
|
|
24
10
|
-webkit-print-color-adjust: exact;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { AsyncCaptureTracker } from "../async-capture-tracker";
|
|
5
|
+
|
|
6
|
+
describe("AsyncCaptureTracker", () => {
|
|
7
|
+
let tracker: AsyncCaptureTracker<string, string>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tracker = new AsyncCaptureTracker();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("needsCapture", () => {
|
|
14
|
+
it("should return true for keys not yet captured", () => {
|
|
15
|
+
expect(tracker.needsCapture("a", "value1")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should return false for keys captured with the same value", () => {
|
|
19
|
+
const handle = tracker.startCapture("a", "value1");
|
|
20
|
+
handle.markCaptured("result");
|
|
21
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should return true for keys captured with a different value", () => {
|
|
25
|
+
const handle = tracker.startCapture("a", "value1");
|
|
26
|
+
handle.markCaptured("result");
|
|
27
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return false for keys in-flight with the same value", () => {
|
|
31
|
+
tracker.startCapture("a", "value1");
|
|
32
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should return true for keys in-flight with a different value", () => {
|
|
36
|
+
tracker.startCapture("a", "value1");
|
|
37
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return true for keys that failed (returned to idle)", () => {
|
|
41
|
+
const handle = tracker.startCapture("a", "value1");
|
|
42
|
+
handle.markFailed();
|
|
43
|
+
expect(tracker.needsCapture("a", "value1")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("startCapture", () => {
|
|
48
|
+
it("should return a handle with a non-aborted signal", () => {
|
|
49
|
+
const handle = tracker.startCapture("a", "v");
|
|
50
|
+
expect(handle.signal.aborted).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should abort only the same key's previous signal", () => {
|
|
54
|
+
const handleA = tracker.startCapture("a", "v1");
|
|
55
|
+
const handleB = tracker.startCapture("b", "v1");
|
|
56
|
+
const handleA2 = tracker.startCapture("a", "v2");
|
|
57
|
+
|
|
58
|
+
expect(handleA.signal.aborted).toBe(true);
|
|
59
|
+
expect(handleB.signal.aborted).toBe(false);
|
|
60
|
+
expect(handleA2.signal.aborted).toBe(false);
|
|
61
|
+
|
|
62
|
+
// Unrelated key still completes independently
|
|
63
|
+
handleB.markCaptured("result-b");
|
|
64
|
+
expect(tracker.needsCapture("b", "v1")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should not abort other keys when one is re-captured", () => {
|
|
68
|
+
tracker.startCapture("a", "v");
|
|
69
|
+
tracker.startCapture("b", "v");
|
|
70
|
+
tracker.startCapture("c", "v");
|
|
71
|
+
|
|
72
|
+
const handleB2 = tracker.startCapture("b", "v2");
|
|
73
|
+
|
|
74
|
+
expect(tracker.needsCapture("a", "v")).toBe(false); // still in-flight
|
|
75
|
+
expect(tracker.needsCapture("c", "v")).toBe(false); // still in-flight
|
|
76
|
+
expect(handleB2.signal.aborted).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should mark key as in-flight", () => {
|
|
80
|
+
tracker.startCapture("a", "v");
|
|
81
|
+
expect(tracker.isCapturing).toBe(true);
|
|
82
|
+
expect(tracker.needsCapture("a", "v")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("handle.markCaptured", () => {
|
|
87
|
+
it("should remove key from in-flight", () => {
|
|
88
|
+
const handle = tracker.startCapture("a", "v");
|
|
89
|
+
expect(tracker.isCapturing).toBe(true);
|
|
90
|
+
handle.markCaptured("result");
|
|
91
|
+
expect(tracker.isCapturing).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should persist the captured value", () => {
|
|
95
|
+
const handle = tracker.startCapture("a", "value1");
|
|
96
|
+
handle.markCaptured("result");
|
|
97
|
+
expect(tracker.needsCapture("a", "value1")).toBe(false);
|
|
98
|
+
expect(tracker.needsCapture("a", "value2")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should update the captured value on re-capture", () => {
|
|
102
|
+
const h1 = tracker.startCapture("a", "v1");
|
|
103
|
+
h1.markCaptured("r1");
|
|
104
|
+
|
|
105
|
+
const h2 = tracker.startCapture("a", "v2");
|
|
106
|
+
h2.markCaptured("r2");
|
|
107
|
+
|
|
108
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true);
|
|
109
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should be a no-op when handle is stale (superseded)", () => {
|
|
113
|
+
const handle1 = tracker.startCapture("a", "v1");
|
|
114
|
+
const handle2 = tracker.startCapture("a", "v2");
|
|
115
|
+
|
|
116
|
+
// handle1 is stale — markCaptured should not affect state
|
|
117
|
+
handle1.markCaptured("stale-result");
|
|
118
|
+
|
|
119
|
+
// "a" should still be in-flight with v2
|
|
120
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false); // in-flight
|
|
121
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true); // not captured
|
|
122
|
+
expect(tracker.isCapturing).toBe(true);
|
|
123
|
+
|
|
124
|
+
// handle2 still works
|
|
125
|
+
handle2.markCaptured("real-result");
|
|
126
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false); // captured
|
|
127
|
+
expect(tracker.isCapturing).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("handle.markFailed", () => {
|
|
132
|
+
it("should remove key from in-flight", () => {
|
|
133
|
+
const handle = tracker.startCapture("a", "v");
|
|
134
|
+
handle.markFailed();
|
|
135
|
+
expect(tracker.isCapturing).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should not add a captured entry", () => {
|
|
139
|
+
const handle = tracker.startCapture("a", "v");
|
|
140
|
+
handle.markFailed();
|
|
141
|
+
expect(tracker.needsCapture("a", "v")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should not clear a previously captured value", () => {
|
|
145
|
+
const h1 = tracker.startCapture("a", "v1");
|
|
146
|
+
h1.markCaptured("r1");
|
|
147
|
+
|
|
148
|
+
const h2 = tracker.startCapture("a", "v2");
|
|
149
|
+
h2.markFailed();
|
|
150
|
+
|
|
151
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
152
|
+
expect(tracker.needsCapture("a", "v2")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should be a no-op when handle is stale", () => {
|
|
156
|
+
const handle1 = tracker.startCapture("a", "v1");
|
|
157
|
+
tracker.startCapture("a", "v2");
|
|
158
|
+
|
|
159
|
+
handle1.markFailed();
|
|
160
|
+
|
|
161
|
+
// "a" should still be in-flight with v2
|
|
162
|
+
expect(tracker.isCapturing).toBe(true);
|
|
163
|
+
expect(tracker.needsCapture("a", "v2")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("waitForInFlight", () => {
|
|
168
|
+
it("should return a promise for in-flight key with same value", async () => {
|
|
169
|
+
const handle = tracker.startCapture("a", "v1");
|
|
170
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
171
|
+
expect(promise).not.toBeNull();
|
|
172
|
+
|
|
173
|
+
handle.markCaptured("result-a");
|
|
174
|
+
const result = await promise;
|
|
175
|
+
expect(result).toBe("result-a");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should return null for captured key (not in-flight)", () => {
|
|
179
|
+
const handle = tracker.startCapture("a", "v1");
|
|
180
|
+
handle.markCaptured("result");
|
|
181
|
+
expect(tracker.waitForInFlight("a", "v1")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should return null for key not being tracked", () => {
|
|
185
|
+
expect(tracker.waitForInFlight("a", "v1")).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("should return null for in-flight key with different value", () => {
|
|
189
|
+
tracker.startCapture("a", "v1");
|
|
190
|
+
expect(tracker.waitForInFlight("a", "v2")).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should resolve with undefined when capture fails", async () => {
|
|
194
|
+
const handle = tracker.startCapture("a", "v1");
|
|
195
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
196
|
+
|
|
197
|
+
handle.markFailed();
|
|
198
|
+
const result = await promise;
|
|
199
|
+
expect(result).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should resolve with undefined when capture is superseded", async () => {
|
|
203
|
+
tracker.startCapture("a", "v1");
|
|
204
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
205
|
+
|
|
206
|
+
// Supersede with new capture
|
|
207
|
+
tracker.startCapture("a", "v2");
|
|
208
|
+
const result = await promise;
|
|
209
|
+
expect(result).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should resolve with undefined when aborted", async () => {
|
|
213
|
+
tracker.startCapture("a", "v1");
|
|
214
|
+
const promise = tracker.waitForInFlight("a", "v1");
|
|
215
|
+
|
|
216
|
+
tracker.abort();
|
|
217
|
+
const result = await promise;
|
|
218
|
+
expect(result).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("should allow multiple waiters on the same key", async () => {
|
|
222
|
+
const handle = tracker.startCapture("a", "v1");
|
|
223
|
+
const p1 = tracker.waitForInFlight("a", "v1");
|
|
224
|
+
const p2 = tracker.waitForInFlight("a", "v1");
|
|
225
|
+
|
|
226
|
+
handle.markCaptured("shared-result");
|
|
227
|
+
|
|
228
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
229
|
+
expect(r1).toBe("shared-result");
|
|
230
|
+
expect(r2).toBe("shared-result");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("prune", () => {
|
|
235
|
+
it("should remove captured entries for keys not in the set", () => {
|
|
236
|
+
const hA = tracker.startCapture("a", "v1");
|
|
237
|
+
hA.markCaptured("r1");
|
|
238
|
+
const hB = tracker.startCapture("b", "v2");
|
|
239
|
+
hB.markCaptured("r2");
|
|
240
|
+
|
|
241
|
+
tracker.prune(new Set(["a"]));
|
|
242
|
+
|
|
243
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
244
|
+
expect(tracker.needsCapture("b", "v2")).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should abort and remove in-flight entries for pruned keys", () => {
|
|
248
|
+
const handleA = tracker.startCapture("a", "v");
|
|
249
|
+
const handleB = tracker.startCapture("b", "v");
|
|
250
|
+
|
|
251
|
+
tracker.prune(new Set(["a"]));
|
|
252
|
+
|
|
253
|
+
expect(handleA.signal.aborted).toBe(false);
|
|
254
|
+
expect(handleB.signal.aborted).toBe(true);
|
|
255
|
+
expect(tracker.needsCapture("b", "v")).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should resolve pruned in-flight deferreds with undefined", async () => {
|
|
259
|
+
tracker.startCapture("b", "v");
|
|
260
|
+
const promise = tracker.waitForInFlight("b", "v");
|
|
261
|
+
|
|
262
|
+
tracker.prune(new Set(["a"]));
|
|
263
|
+
const result = await promise;
|
|
264
|
+
expect(result).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should do nothing if all keys are current", () => {
|
|
268
|
+
const hA = tracker.startCapture("a", "v1");
|
|
269
|
+
hA.markCaptured("r1");
|
|
270
|
+
const hB = tracker.startCapture("b", "v2");
|
|
271
|
+
hB.markCaptured("r2");
|
|
272
|
+
|
|
273
|
+
tracker.prune(new Set(["a", "b"]));
|
|
274
|
+
|
|
275
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
276
|
+
expect(tracker.needsCapture("b", "v2")).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("abort", () => {
|
|
281
|
+
it("should abort all in-flight signals", () => {
|
|
282
|
+
const handleA = tracker.startCapture("a", "v");
|
|
283
|
+
const handleB = tracker.startCapture("b", "v");
|
|
284
|
+
tracker.abort();
|
|
285
|
+
expect(handleA.signal.aborted).toBe(true);
|
|
286
|
+
expect(handleB.signal.aborted).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("should clear in-flight state", () => {
|
|
290
|
+
tracker.startCapture("a", "v");
|
|
291
|
+
tracker.abort();
|
|
292
|
+
expect(tracker.isCapturing).toBe(false);
|
|
293
|
+
expect(tracker.needsCapture("a", "v")).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("should not clear captured state", () => {
|
|
297
|
+
const handle = tracker.startCapture("a", "v1");
|
|
298
|
+
handle.markCaptured("result");
|
|
299
|
+
tracker.abort();
|
|
300
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("reset", () => {
|
|
305
|
+
it("should clear all state", () => {
|
|
306
|
+
const hA = tracker.startCapture("a", "v1");
|
|
307
|
+
hA.markCaptured("r1");
|
|
308
|
+
tracker.startCapture("b", "v2");
|
|
309
|
+
|
|
310
|
+
tracker.reset();
|
|
311
|
+
|
|
312
|
+
expect(tracker.isCapturing).toBe(false);
|
|
313
|
+
expect(tracker.needsCapture("a", "v1")).toBe(true);
|
|
314
|
+
expect(tracker.needsCapture("b", "v2")).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("isCapturing", () => {
|
|
319
|
+
it("should be false when nothing is in-flight", () => {
|
|
320
|
+
expect(tracker.isCapturing).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("should be true when items are in-flight", () => {
|
|
324
|
+
tracker.startCapture("a", "v");
|
|
325
|
+
expect(tracker.isCapturing).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should be false when all items are completed", () => {
|
|
329
|
+
const hA = tracker.startCapture("a", "v");
|
|
330
|
+
const hB = tracker.startCapture("b", "v");
|
|
331
|
+
hA.markCaptured("r");
|
|
332
|
+
hB.markFailed();
|
|
333
|
+
expect(tracker.isCapturing).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("granular abort", () => {
|
|
338
|
+
it("should allow independent completion of unrelated keys", () => {
|
|
339
|
+
const hA = tracker.startCapture("a", "v1");
|
|
340
|
+
tracker.startCapture("b", "v1");
|
|
341
|
+
const hC = tracker.startCapture("c", "v1");
|
|
342
|
+
|
|
343
|
+
hA.markCaptured("ra");
|
|
344
|
+
tracker.startCapture("b", "v2"); // abort old "b", start new
|
|
345
|
+
hC.markCaptured("rc");
|
|
346
|
+
|
|
347
|
+
expect(tracker.needsCapture("a", "v1")).toBe(false);
|
|
348
|
+
expect(tracker.needsCapture("c", "v1")).toBe(false);
|
|
349
|
+
expect(tracker.needsCapture("b", "v2")).toBe(false); // in-flight
|
|
350
|
+
expect(tracker.needsCapture("b", "v1")).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|