@weasel-js/labkit 1.1.0 → 1.3.0-pre.0
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/README.md +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { render } from '@testing-library/react';
|
|
2
2
|
import type { ReactElement } from 'react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { DEFAULT_VIEW } from '../state/view';
|
|
4
5
|
import { defineInstrument } from './defineInstrument';
|
|
5
6
|
import type { RenderContext } from './types';
|
|
6
7
|
import { validateConfigSchema } from './validateConfigSchema';
|
|
@@ -85,7 +86,14 @@ describe('SineWave instrument smoke test', () => {
|
|
|
85
86
|
config: SineWaveInstrument.defaultConfig(),
|
|
86
87
|
setState: vi.fn(),
|
|
87
88
|
setConfig: vi.fn(),
|
|
88
|
-
trial: {
|
|
89
|
+
trial: {
|
|
90
|
+
id: 'w',
|
|
91
|
+
view: DEFAULT_VIEW,
|
|
92
|
+
setView: vi.fn(),
|
|
93
|
+
zoom: 1,
|
|
94
|
+
setZoom: vi.fn(),
|
|
95
|
+
activeToolId: null,
|
|
96
|
+
},
|
|
89
97
|
emit: vi.fn(),
|
|
90
98
|
};
|
|
91
99
|
const { container } = render(SineWaveInstrument.render(ctx) as ReactElement);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import { f } from '../config/builder';
|
|
3
|
+
import type { ConfigOf } from '../config/types';
|
|
2
4
|
import { defineInstrument } from './defineInstrument';
|
|
3
5
|
import type { Instrument } from './types';
|
|
4
6
|
|
|
5
7
|
describe('defineInstrument', () => {
|
|
6
|
-
it('returns
|
|
8
|
+
it('returns a legacy spec by reference', () => {
|
|
7
9
|
const spec: Instrument<{ n: number }, { x: number }> = {
|
|
8
10
|
name: 'Test',
|
|
9
11
|
defaultConfig: () => ({ x: 1 }),
|
|
@@ -24,4 +26,39 @@ describe('defineInstrument', () => {
|
|
|
24
26
|
expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{ flag: boolean }>();
|
|
25
27
|
expectTypeOf(inst.initialState({ flag: true })).toEqualTypeOf<{ count: number }>();
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
it('synthesizes defaultConfig from a builder schema', () => {
|
|
31
|
+
const inst = defineInstrument({
|
|
32
|
+
name: 'Schema',
|
|
33
|
+
config: f.schema({ showGrid: f.boolean(true), cellSize: f.number(20) }),
|
|
34
|
+
initialState: () => ({}),
|
|
35
|
+
render: () => null,
|
|
36
|
+
});
|
|
37
|
+
expect(inst.defaultConfig()).toEqual({ showGrid: true, cellSize: 20 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('infers TC from the schema', () => {
|
|
41
|
+
const config = f.schema({ showGrid: f.boolean(true), mode: f.enum('fast', ['fast', 'slow']) });
|
|
42
|
+
const inst = defineInstrument<{ n: number }, ConfigOf<typeof config>>({
|
|
43
|
+
name: 'Schema',
|
|
44
|
+
config,
|
|
45
|
+
initialState: () => ({ n: 0 }),
|
|
46
|
+
render: () => null,
|
|
47
|
+
});
|
|
48
|
+
expectTypeOf(inst.defaultConfig()).toEqualTypeOf<{
|
|
49
|
+
showGrid: boolean;
|
|
50
|
+
mode: 'fast' | 'slow';
|
|
51
|
+
}>();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('leaves an explicit defaultConfig alone even alongside a schema', () => {
|
|
55
|
+
const inst = defineInstrument({
|
|
56
|
+
name: 'Both',
|
|
57
|
+
config: f.schema({ a: f.number(1) }),
|
|
58
|
+
defaultConfig: () => ({ a: 99 }),
|
|
59
|
+
initialState: () => ({}),
|
|
60
|
+
render: () => null,
|
|
61
|
+
});
|
|
62
|
+
expect(inst.defaultConfig()).toEqual({ a: 99 });
|
|
63
|
+
});
|
|
27
64
|
});
|
|
@@ -1,7 +1,34 @@
|
|
|
1
|
+
import type { ConfigSchema } from '../config/types';
|
|
1
2
|
import type { Instrument } from './types';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
export
|
|
6
|
-
|
|
4
|
+
/** An instrument as it is written: either the legacy `defaultConfig` (plus an
|
|
5
|
+
* optional `configSchema`), or a `config` schema that supplies both. */
|
|
6
|
+
export type InstrumentSpec<TS, TC, TItem = unknown> =
|
|
7
|
+
| Instrument<TS, TC, TItem>
|
|
8
|
+
| (Omit<Instrument<TS, TC, TItem>, 'defaultConfig'> & { config: ConfigSchema<TC> });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalizes an instrument spec, and infers its state and config types from
|
|
12
|
+
* the literal rather than making them be written out.
|
|
13
|
+
*
|
|
14
|
+
* A spec declaring `config` gets its `defaultConfig` synthesized from the
|
|
15
|
+
* schema, so nothing downstream branches on which way the config was
|
|
16
|
+
* declared. A spec written the legacy way is returned unchanged, by
|
|
17
|
+
* reference.
|
|
18
|
+
*
|
|
19
|
+
* Resolving the schema into controls does not happen here: rules are
|
|
20
|
+
* lab-scoped and this runs at module load. `useConfigSchema` does that.
|
|
21
|
+
*
|
|
22
|
+
* TypeScript infers all three type arguments or none, so a call that names
|
|
23
|
+
* `TS` and `TC` leaves `TItem` at `unknown` and a job's `onItem` needs a cast.
|
|
24
|
+
* Name the item type too, or pass no type arguments at all.
|
|
25
|
+
*/
|
|
26
|
+
export function defineInstrument<TS, TC, TItem = unknown>(
|
|
27
|
+
spec: InstrumentSpec<TS, TC, TItem>,
|
|
28
|
+
): Instrument<TS, TC, TItem> {
|
|
29
|
+
if (spec.config && !('defaultConfig' in spec && spec.defaultConfig)) {
|
|
30
|
+
const schema = spec.config;
|
|
31
|
+
return { ...spec, defaultConfig: () => schema.defaults() } as Instrument<TS, TC, TItem>;
|
|
32
|
+
}
|
|
33
|
+
return spec as Instrument<TS, TC, TItem>;
|
|
7
34
|
}
|
package/src/instrument/index.ts
CHANGED
package/src/instrument/types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
import type { TrialContribution } from '../chrome/types';
|
|
3
|
+
import type { ConfigSchema } from '../config/types';
|
|
2
4
|
import type { ConfigField } from '../controls/types';
|
|
5
|
+
import type { JobCapability, JobHandle } from '../job/types';
|
|
6
|
+
import type { ToolCapability } from '../tools/types';
|
|
3
7
|
|
|
4
8
|
/** What an instrument's `render` is handed: its state and config, the setters
|
|
5
9
|
* for both, the trial it is mounted in, and a way to emit named events. */
|
|
@@ -10,10 +14,21 @@ export interface RenderContext<TS = unknown, TC = unknown> {
|
|
|
10
14
|
setConfig: (key: keyof TC, value: unknown) => void;
|
|
11
15
|
trial: {
|
|
12
16
|
id: string;
|
|
17
|
+
/** The trial's view, in whatever shape this instrument chose. labkit persists
|
|
18
|
+
* it and restores it on Reset without ever reading into it. */
|
|
19
|
+
view: unknown;
|
|
20
|
+
setView: (next: unknown) => void;
|
|
21
|
+
/** 2D convenience over `view`. Reads 1 and writes nothing when the trial holds
|
|
22
|
+
* a view that is not the 2D one. */
|
|
13
23
|
zoom: number;
|
|
14
24
|
setZoom: (z: number) => void;
|
|
25
|
+
/** Resolved active tool: this trial's slot, or the lab's. Null when neither
|
|
26
|
+
* holds one. */
|
|
27
|
+
activeToolId: string | null;
|
|
15
28
|
};
|
|
16
29
|
emit: (event: string) => void;
|
|
30
|
+
/** Present only when the instrument declares a `job`. */
|
|
31
|
+
job?: JobHandle;
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
/** One 2D canvas layer of an instrument, drawn in declaration order.
|
|
@@ -31,12 +46,18 @@ export interface CanvasLayer<TS = unknown, TC = unknown> {
|
|
|
31
46
|
export interface CanvasCapability<TS = unknown, TC = unknown> {
|
|
32
47
|
layers: CanvasLayer<TS, TC>[];
|
|
33
48
|
initialView?: { zoom: number; pan: { x: number; y: number } };
|
|
49
|
+
/** Widens `usePanZoom`'s default clamp; the opening zoom stays reachable
|
|
50
|
+
* regardless of these. */
|
|
51
|
+
minZoom?: number;
|
|
52
|
+
maxZoom?: number;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/** Declares which of an instrument's layers the trial should offer
|
|
37
56
|
* show/hide controls for. */
|
|
38
57
|
export interface LayerCapability {
|
|
39
|
-
|
|
58
|
+
/** In list order. A bare string is a layer id that doubles as its own label;
|
|
59
|
+
* give a descriptor instead to label a layer or mark it `alwaysOn`. */
|
|
60
|
+
ids: readonly (string | LayerDescriptor)[];
|
|
40
61
|
}
|
|
41
62
|
|
|
42
63
|
/** Declares that an instrument accepts items dragged from a palette: what the
|
|
@@ -82,10 +103,16 @@ export type DragFeedback = { ok: boolean; reason?: string };
|
|
|
82
103
|
* a canvas, a layer list, palette drag-and-drop, undo. Declaring a capability
|
|
83
104
|
* is what makes the trial provide the corresponding chrome.
|
|
84
105
|
*/
|
|
85
|
-
export interface Instrument<TS = unknown, TC = unknown> {
|
|
106
|
+
export interface Instrument<TS = unknown, TC = unknown, TItem = unknown> {
|
|
86
107
|
name: string;
|
|
87
108
|
defaultConfig: () => TC;
|
|
88
109
|
initialState: (config: TC) => TS;
|
|
110
|
+
/** The instrument's config, declared once: values, types and controls.
|
|
111
|
+
* Supplying this makes `defaultConfig` optional — `defineInstrument`
|
|
112
|
+
* synthesizes it. Prefer it over `defaultConfig` + `configSchema`. */
|
|
113
|
+
config?: ConfigSchema<TC>;
|
|
114
|
+
/** @deprecated Declare `config` instead; this repeats what `TC` already
|
|
115
|
+
* says and nothing holds the two to one answer. */
|
|
89
116
|
configSchema?: () => ConfigField[];
|
|
90
117
|
/** The instrument's DOM. With `canvas`, this renders as an overlay above the
|
|
91
118
|
* layers rather than instead of them; return `null` for canvas only. */
|
|
@@ -97,10 +124,18 @@ export interface Instrument<TS = unknown, TC = unknown> {
|
|
|
97
124
|
layers?: LayerCapability;
|
|
98
125
|
dragDrop?: DragDropCapability<TS, TC>;
|
|
99
126
|
undo?: UndoCapability;
|
|
127
|
+
/** Tools this instrument offers. Declaring them gives the trial a palette
|
|
128
|
+
* region and its own tool slot. */
|
|
129
|
+
tools?: ToolCapability;
|
|
130
|
+
/** Chrome this instrument contributes beyond what its capabilities imply. */
|
|
131
|
+
chrome?: TrialContribution[];
|
|
132
|
+
/** Work too slow to do during a render. The runtime starts it, aborts it on
|
|
133
|
+
* unmount and on a `key` change, and renders progress into the trial. */
|
|
134
|
+
job?: JobCapability<TS, TC, TItem>;
|
|
100
135
|
}
|
|
101
136
|
|
|
102
137
|
/** Instruments as a lab receives them. `any` rather than `unknown` because
|
|
103
138
|
* parameter contravariance keeps a `defineInstrument<TS, TC>` result out of
|
|
104
139
|
* an `Instrument<unknown, unknown>[]`; it is contained to this alias. */
|
|
105
140
|
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
106
|
-
export type InstrumentList = readonly Instrument<any, any>[];
|
|
141
|
+
export type InstrumentList = readonly Instrument<any, any, any>[]; // eslint-disable-line @typescript-eslint/no-explicit-any -- contravariant TC; see above
|
|
@@ -21,12 +21,22 @@ describe('validateConfigSchema', () => {
|
|
|
21
21
|
expect(r.errors).toContain('Duplicate config key: "a"');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it('
|
|
24
|
+
it('accepts an unknown field type — a lab may supply its renderer', () => {
|
|
25
25
|
const fields = [
|
|
26
|
-
{ key: 'x', label: 'X', type: '
|
|
26
|
+
{ key: 'x', label: 'X', type: 'vector2', default: 1 },
|
|
27
27
|
] as unknown as ConfigField[];
|
|
28
28
|
const r = validateConfigSchema(fields);
|
|
29
|
-
expect(r.
|
|
29
|
+
expect(r.valid).toBe(true);
|
|
30
|
+
expect(r.errors).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('still checks keys and labels on an unknown field type', () => {
|
|
34
|
+
const fields = [
|
|
35
|
+
{ key: '', label: '', type: 'vector2', default: 1 },
|
|
36
|
+
] as unknown as ConfigField[];
|
|
37
|
+
const r = validateConfigSchema(fields);
|
|
38
|
+
expect(r.errors).toContain('Field has empty key');
|
|
39
|
+
expect(r.errors).toContain('Field "" has empty label');
|
|
30
40
|
});
|
|
31
41
|
|
|
32
42
|
it('flags slider min >= max', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigField
|
|
1
|
+
import type { ConfigField } from '../controls/types';
|
|
2
2
|
|
|
3
3
|
/** Whether a config schema is usable, and every problem found rather than
|
|
4
4
|
* just the first. */
|
|
@@ -7,11 +7,13 @@ export interface ValidationResult {
|
|
|
7
7
|
errors: string[];
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
const KNOWN_TYPES: ConfigFieldType[] = ['slider', 'checkbox', 'select', 'number', 'text', 'color'];
|
|
11
|
-
|
|
12
10
|
/** Check a config schema for the mistakes that would otherwise surface as a
|
|
13
|
-
* silently broken control: empty or duplicate keys,
|
|
14
|
-
*
|
|
11
|
+
* silently broken control: empty or duplicate keys, and fields whose own
|
|
12
|
+
* constraints do not hold.
|
|
13
|
+
*
|
|
14
|
+
* An unrecognized type is not an error — a lab supplies controls for its own
|
|
15
|
+
* kinds through `ControlPanel`'s `renderers`, and this cannot see them. Such
|
|
16
|
+
* a field simply has no constraints to check here. */
|
|
15
17
|
export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
16
18
|
const errors: string[] = [];
|
|
17
19
|
const seenKeys = new Set<string>();
|
|
@@ -29,11 +31,6 @@ export function validateConfigSchema(fields: ConfigField[]): ValidationResult {
|
|
|
29
31
|
errors.push(`Field "${field.key}" has empty label`);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
if (!KNOWN_TYPES.includes(field.type)) {
|
|
33
|
-
errors.push(`Unknown field type: "${field.type}" on key "${field.key}"`);
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
if (field.type === 'slider') {
|
|
38
35
|
if (!Number.isFinite(field.default)) {
|
|
39
36
|
errors.push(`Field "${field.key}": default must be a finite number`);
|
package/src/job/index.ts
ADDED
package/src/job/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** What a running job reports as it goes. `total` may arrive at any point and may
|
|
2
|
+
* arrive more than once; a job that cannot count up front simply never sends it.
|
|
3
|
+
*
|
|
4
|
+
* `failed` is a first-class event rather than a thrown error because these
|
|
5
|
+
* failures are per item: a run with two failed items is a partial success, and
|
|
6
|
+
* its other items are worth showing. */
|
|
7
|
+
export type JobEvent<T> =
|
|
8
|
+
| { kind: 'total'; total: number }
|
|
9
|
+
| { kind: 'item'; item: T }
|
|
10
|
+
| { kind: 'failed'; index: number; error: string };
|
|
11
|
+
|
|
12
|
+
/** Where a job is. `idle` before its first run and after a cancel; `done` when the
|
|
13
|
+
* iterable finished, whether or not items failed along the way. */
|
|
14
|
+
export type JobStatus = 'idle' | 'running' | 'done' | 'error';
|
|
15
|
+
|
|
16
|
+
/** Declares that an instrument has work too slow to do during a render: what to
|
|
17
|
+
* run, when to re-run it, and how each result folds into state. */
|
|
18
|
+
export interface JobCapability<TS = unknown, TC = unknown, TItem = unknown> {
|
|
19
|
+
/** Re-run whenever this value changes, compared element-wise. A job with no
|
|
20
|
+
* `key` runs only when something calls `start()`. */
|
|
21
|
+
key?: (config: TC, state: TS) => readonly unknown[];
|
|
22
|
+
/** Start on mount and on every `key` change. Default false. */
|
|
23
|
+
auto?: boolean;
|
|
24
|
+
run: (args: { config: TC; state: TS; signal: AbortSignal }) => AsyncIterable<JobEvent<TItem>>;
|
|
25
|
+
/** Fold one result into state. Called once per `item` event, in arrival order. */
|
|
26
|
+
onItem: (item: TItem, state: TS) => TS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One item that failed, and why. */
|
|
30
|
+
export interface JobFailure {
|
|
31
|
+
index: number;
|
|
32
|
+
error: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What `RenderContext.job` exposes. Present only when the instrument declares the
|
|
36
|
+
* capability; `undefined` otherwise. */
|
|
37
|
+
export interface JobHandle {
|
|
38
|
+
status: JobStatus;
|
|
39
|
+
done: number;
|
|
40
|
+
/** Null until the job reports a total, and forever if it never does. */
|
|
41
|
+
total: number | null;
|
|
42
|
+
failures: readonly JobFailure[];
|
|
43
|
+
/** The error that ended the run, when `status` is `'error'`. */
|
|
44
|
+
error: string | null;
|
|
45
|
+
start: () => void;
|
|
46
|
+
cancel: () => void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { act, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { JobCapability, JobEvent, JobHandle } from './types';
|
|
5
|
+
import { useJob } from './useJob';
|
|
6
|
+
|
|
7
|
+
interface State {
|
|
8
|
+
items: number[];
|
|
9
|
+
}
|
|
10
|
+
interface Config {
|
|
11
|
+
n: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Yields 0..n-1, pausing between each so a test can cancel mid-run. */
|
|
15
|
+
async function* counter(
|
|
16
|
+
n: number,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
failAt?: number,
|
|
19
|
+
): AsyncGenerator<JobEvent<number>> {
|
|
20
|
+
yield { kind: 'total', total: n };
|
|
21
|
+
for (let i = 0; i < n; i++) {
|
|
22
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
23
|
+
if (signal.aborted) return;
|
|
24
|
+
if (i === failAt) {
|
|
25
|
+
yield { kind: 'failed', index: i, error: 'frame died' };
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
yield { kind: 'item', item: i };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const capability = (failAt?: number): JobCapability<State, Config, number> => ({
|
|
33
|
+
run: ({ config, signal }) => counter(config.n, signal, failAt),
|
|
34
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
interface HarnessProps {
|
|
38
|
+
capability: JobCapability<State, Config, number>;
|
|
39
|
+
config: Config;
|
|
40
|
+
onHandle: (h: JobHandle, s: State) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Harness({ capability: cap, config, onHandle }: HarnessProps) {
|
|
44
|
+
const [state, setState] = useState<State>({ items: [] });
|
|
45
|
+
const job = useJob({ capability: cap, config, state, setState });
|
|
46
|
+
onHandle(job, state);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('useJob', () => {
|
|
51
|
+
it('folds each item into state and counts progress', async () => {
|
|
52
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
53
|
+
render(
|
|
54
|
+
<Harness
|
|
55
|
+
capability={capability()}
|
|
56
|
+
config={{ n: 3 }}
|
|
57
|
+
onHandle={(h, s) => {
|
|
58
|
+
seen.handle = h;
|
|
59
|
+
seen.state = s;
|
|
60
|
+
}}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
act(() => seen.handle?.start());
|
|
64
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
65
|
+
expect(seen.state.items).toEqual([0, 1, 2]);
|
|
66
|
+
expect(seen.handle?.done).toBe(3);
|
|
67
|
+
expect(seen.handle?.total).toBe(3);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('counts a failed item without ending the run', async () => {
|
|
71
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
72
|
+
render(
|
|
73
|
+
<Harness
|
|
74
|
+
capability={capability(1)}
|
|
75
|
+
config={{ n: 3 }}
|
|
76
|
+
onHandle={(h, s) => {
|
|
77
|
+
seen.handle = h;
|
|
78
|
+
seen.state = s;
|
|
79
|
+
}}
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
act(() => seen.handle?.start());
|
|
83
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
84
|
+
expect(seen.state.items).toEqual([0, 2]);
|
|
85
|
+
expect(seen.handle?.failures).toEqual([{ index: 1, error: 'frame died' }]);
|
|
86
|
+
expect(seen.handle?.done).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('stops folding results once cancelled', async () => {
|
|
90
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
91
|
+
render(
|
|
92
|
+
<Harness
|
|
93
|
+
capability={capability()}
|
|
94
|
+
config={{ n: 50 }}
|
|
95
|
+
onHandle={(h, s) => {
|
|
96
|
+
seen.handle = h;
|
|
97
|
+
seen.state = s;
|
|
98
|
+
}}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
act(() => seen.handle?.start());
|
|
102
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
103
|
+
act(() => seen.handle?.cancel());
|
|
104
|
+
const atCancel = seen.state.items.length;
|
|
105
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
106
|
+
expect(seen.state.items.length).toBe(atCancel);
|
|
107
|
+
expect(seen.handle?.status).toBe('idle');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('discards results from a run its key superseded', async () => {
|
|
111
|
+
const withKey: JobCapability<State, Config, number> = {
|
|
112
|
+
...capability(),
|
|
113
|
+
key: (config) => [config.n],
|
|
114
|
+
auto: true,
|
|
115
|
+
};
|
|
116
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
117
|
+
const { rerender } = render(
|
|
118
|
+
<Harness
|
|
119
|
+
capability={withKey}
|
|
120
|
+
config={{ n: 40 }}
|
|
121
|
+
onHandle={(h, s) => {
|
|
122
|
+
seen.handle = h;
|
|
123
|
+
seen.state = s;
|
|
124
|
+
}}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
128
|
+
|
|
129
|
+
rerender(
|
|
130
|
+
<Harness
|
|
131
|
+
capability={withKey}
|
|
132
|
+
config={{ n: 2 }}
|
|
133
|
+
onHandle={(h, s) => {
|
|
134
|
+
seen.handle = h;
|
|
135
|
+
seen.state = s;
|
|
136
|
+
}}
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
await waitFor(() => expect(seen.handle?.status).toBe('done'));
|
|
140
|
+
|
|
141
|
+
// The superseded 40-item run cannot have contributed: the winner yields two.
|
|
142
|
+
expect(seen.state.items).toEqual([0, 1]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('aborts on unmount', async () => {
|
|
146
|
+
const aborted = vi.fn();
|
|
147
|
+
const watching: JobCapability<State, Config, number> = {
|
|
148
|
+
run: ({ signal }) => {
|
|
149
|
+
signal.addEventListener('abort', aborted);
|
|
150
|
+
return counter(50, signal);
|
|
151
|
+
},
|
|
152
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
153
|
+
};
|
|
154
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
155
|
+
const { unmount } = render(
|
|
156
|
+
<Harness
|
|
157
|
+
capability={watching}
|
|
158
|
+
config={{ n: 50 }}
|
|
159
|
+
onHandle={(h) => {
|
|
160
|
+
seen.handle = h;
|
|
161
|
+
}}
|
|
162
|
+
/>,
|
|
163
|
+
);
|
|
164
|
+
act(() => seen.handle?.start());
|
|
165
|
+
await waitFor(() => expect(seen.handle?.status).toBe('running'));
|
|
166
|
+
unmount();
|
|
167
|
+
expect(aborted).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('reports a thrown error without losing the items already folded', async () => {
|
|
171
|
+
const throwing: JobCapability<State, Config, number> = {
|
|
172
|
+
run: async function* () {
|
|
173
|
+
yield { kind: 'item', item: 7 };
|
|
174
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
175
|
+
throw new Error('the baker died');
|
|
176
|
+
},
|
|
177
|
+
onItem: (item, state) => ({ items: [...state.items, item] }),
|
|
178
|
+
};
|
|
179
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
180
|
+
render(
|
|
181
|
+
<Harness
|
|
182
|
+
capability={throwing}
|
|
183
|
+
config={{ n: 1 }}
|
|
184
|
+
onHandle={(h, s) => {
|
|
185
|
+
seen.handle = h;
|
|
186
|
+
seen.state = s;
|
|
187
|
+
}}
|
|
188
|
+
/>,
|
|
189
|
+
);
|
|
190
|
+
act(() => seen.handle?.start());
|
|
191
|
+
await waitFor(() => expect(seen.handle?.status).toBe('error'));
|
|
192
|
+
expect(seen.handle?.error).toMatch(/the baker died/);
|
|
193
|
+
expect(seen.state.items).toEqual([7]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('does not start on its own without auto', async () => {
|
|
197
|
+
const seen: { handle: JobHandle | null; state: State } = { handle: null, state: { items: [] } };
|
|
198
|
+
render(
|
|
199
|
+
<Harness
|
|
200
|
+
capability={{ ...capability(), key: (c) => [c.n] }}
|
|
201
|
+
config={{ n: 3 }}
|
|
202
|
+
onHandle={(h) => {
|
|
203
|
+
seen.handle = h;
|
|
204
|
+
}}
|
|
205
|
+
/>,
|
|
206
|
+
);
|
|
207
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
208
|
+
expect(seen.handle?.status).toBe('idle');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { JobCapability, JobFailure, JobHandle, JobStatus } from './types';
|
|
3
|
+
|
|
4
|
+
export interface UseJobOptions<TS, TC, TItem> {
|
|
5
|
+
capability: JobCapability<TS, TC, TItem>;
|
|
6
|
+
config: TC;
|
|
7
|
+
state: TS;
|
|
8
|
+
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Progress {
|
|
12
|
+
status: JobStatus;
|
|
13
|
+
done: number;
|
|
14
|
+
total: number | null;
|
|
15
|
+
failures: JobFailure[];
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const IDLE: Progress = { status: 'idle', done: 0, total: null, failures: [], error: null };
|
|
20
|
+
|
|
21
|
+
function sameKey(a: readonly unknown[] | null, b: readonly unknown[] | null): boolean {
|
|
22
|
+
if (a === null || b === null) return a === b;
|
|
23
|
+
return a.length === b.length && a.every((v, i) => Object.is(v, b[i]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useJob<TS, TC, TItem>({
|
|
27
|
+
capability,
|
|
28
|
+
config,
|
|
29
|
+
state,
|
|
30
|
+
setState,
|
|
31
|
+
}: UseJobOptions<TS, TC, TItem>): JobHandle {
|
|
32
|
+
const [progress, setProgress] = useState<Progress>(IDLE);
|
|
33
|
+
|
|
34
|
+
// A run is identified by a token. Every result checks its token before touching
|
|
35
|
+
// state, so a superseded run finishes harmlessly instead of racing the winner.
|
|
36
|
+
const token = useRef(0);
|
|
37
|
+
const abort = useRef<AbortController | null>(null);
|
|
38
|
+
|
|
39
|
+
// Read through refs: `run` is called once per run and must see the values as of
|
|
40
|
+
// that moment rather than re-subscribing on every render.
|
|
41
|
+
const capRef = useRef(capability);
|
|
42
|
+
capRef.current = capability;
|
|
43
|
+
const configRef = useRef(config);
|
|
44
|
+
configRef.current = config;
|
|
45
|
+
const stateRef = useRef(state);
|
|
46
|
+
stateRef.current = state;
|
|
47
|
+
const setStateRef = useRef(setState);
|
|
48
|
+
setStateRef.current = setState;
|
|
49
|
+
|
|
50
|
+
const cancel = useCallback(() => {
|
|
51
|
+
token.current += 1;
|
|
52
|
+
abort.current?.abort();
|
|
53
|
+
abort.current = null;
|
|
54
|
+
setProgress(IDLE);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const start = useCallback(() => {
|
|
58
|
+
token.current += 1;
|
|
59
|
+
const mine = token.current;
|
|
60
|
+
abort.current?.abort();
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
abort.current = controller;
|
|
63
|
+
setProgress({ ...IDLE, status: 'running' });
|
|
64
|
+
|
|
65
|
+
void (async () => {
|
|
66
|
+
try {
|
|
67
|
+
const iterable = capRef.current.run({
|
|
68
|
+
config: configRef.current,
|
|
69
|
+
state: stateRef.current,
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
});
|
|
72
|
+
for await (const event of iterable) {
|
|
73
|
+
if (token.current !== mine) return;
|
|
74
|
+
if (event.kind === 'total') {
|
|
75
|
+
setProgress((p) => ({ ...p, total: event.total }));
|
|
76
|
+
} else if (event.kind === 'failed') {
|
|
77
|
+
setProgress((p) => ({
|
|
78
|
+
...p,
|
|
79
|
+
failures: [...p.failures, { index: event.index, error: event.error }],
|
|
80
|
+
}));
|
|
81
|
+
} else {
|
|
82
|
+
const fold = capRef.current.onItem;
|
|
83
|
+
const item = event.item;
|
|
84
|
+
setStateRef.current((prev) => fold(item, prev));
|
|
85
|
+
setProgress((p) => ({ ...p, done: p.done + 1 }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (token.current !== mine) return;
|
|
89
|
+
setProgress((p) => ({ ...p, status: 'done' }));
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (token.current !== mine) return;
|
|
92
|
+
setProgress((p) => ({
|
|
93
|
+
...p,
|
|
94
|
+
status: 'error',
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// Re-run when the declared key changes. `auto` covers the first mount too.
|
|
102
|
+
const lastKey = useRef<readonly unknown[] | null>(null);
|
|
103
|
+
const started = useRef(false);
|
|
104
|
+
const auto = capability.auto === true;
|
|
105
|
+
const key = capability.key ? capability.key(config, state) : null;
|
|
106
|
+
// `key` is compared element-wise inside; depending on the array identity here
|
|
107
|
+
// would restart the job on every render.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!auto) return;
|
|
110
|
+
if (started.current && sameKey(lastKey.current, key)) return;
|
|
111
|
+
lastKey.current = key;
|
|
112
|
+
started.current = true;
|
|
113
|
+
start();
|
|
114
|
+
}, [auto, key, start]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
115
|
+
|
|
116
|
+
useEffect(
|
|
117
|
+
() => () => {
|
|
118
|
+
token.current += 1;
|
|
119
|
+
abort.current?.abort();
|
|
120
|
+
abort.current = null;
|
|
121
|
+
},
|
|
122
|
+
[],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
status: progress.status,
|
|
127
|
+
done: progress.done,
|
|
128
|
+
total: progress.total,
|
|
129
|
+
failures: progress.failures,
|
|
130
|
+
error: progress.error,
|
|
131
|
+
start,
|
|
132
|
+
cancel,
|
|
133
|
+
};
|
|
134
|
+
}
|