@weasel-js/labkit 1.1.0 → 1.2.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.
Files changed (79) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-C_XboUpz.d.ts} +48 -6
  3. package/dist/_dts/{index-iAP6XbH3.d.ts → index-JFAYj5Tv.d.ts} +12 -0
  4. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  5. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-BMNIx3Cy.d.ts} +5 -9
  6. package/dist/canvas/index.d.ts +2 -1
  7. package/dist/{chunk-C6GJKPUI.js → chunk-73KA7WBO.js} +69 -63
  8. package/dist/chunk-73KA7WBO.js.map +1 -0
  9. package/dist/{chunk-DJLDIRFN.js → chunk-BOHF3PQO.js} +4375 -3720
  10. package/dist/chunk-BOHF3PQO.js.map +1 -0
  11. package/dist/{chunk-NRKWVTVT.js → chunk-G5TJVQQT.js} +3 -3
  12. package/dist/{chunk-NRKWVTVT.js.map → chunk-G5TJVQQT.js.map} +1 -1
  13. package/dist/chunk-LN6JDUGB.js +106 -0
  14. package/dist/chunk-LN6JDUGB.js.map +1 -0
  15. package/dist/chunk-THBG7FQZ.js +167 -0
  16. package/dist/chunk-THBG7FQZ.js.map +1 -0
  17. package/dist/{chunk-3TYUJR7Z.js → chunk-VUU5UXHE.js} +6 -3
  18. package/dist/chunk-VUU5UXHE.js.map +1 -0
  19. package/dist/dragdrop/index.d.ts +2 -1
  20. package/dist/index.d.ts +71 -11
  21. package/dist/index.js +165 -22
  22. package/dist/index.js.map +1 -1
  23. package/dist/job/index.d.ts +13 -0
  24. package/dist/job/index.js +3 -0
  25. package/dist/job/index.js.map +1 -0
  26. package/dist/layers/index.d.ts +3 -2
  27. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  28. package/dist/passthrough/weasel-canvas.js +1 -1
  29. package/dist/passthrough/weasel-ui.d.ts +20 -4
  30. package/dist/passthrough/weasel-ui.js +2 -2
  31. package/dist/state/index.d.ts +2 -2
  32. package/dist/state/index.js +2 -2
  33. package/dist/styles.css +10 -0
  34. package/dist/surface/index.d.ts +77 -0
  35. package/dist/surface/index.js +3 -0
  36. package/dist/surface/index.js.map +1 -0
  37. package/dist/ui/layers/index.js +3 -3
  38. package/dist/undo/index.d.ts +2 -1
  39. package/package.json +9 -1
  40. package/src/canvas/AGENTS.md +8 -0
  41. package/src/canvas/useOrbit.test.ts +71 -0
  42. package/src/canvas/useOrbit.ts +149 -0
  43. package/src/index.test.ts +18 -0
  44. package/src/index.ts +13 -0
  45. package/src/instrument/SineWave.smoke.test.tsx +2 -1
  46. package/src/instrument/types.ts +13 -1
  47. package/src/job/index.ts +3 -0
  48. package/src/job/types.ts +47 -0
  49. package/src/job/useJob.test.tsx +210 -0
  50. package/src/job/useJob.ts +134 -0
  51. package/src/lab/Lab.stories.tsx +1 -2
  52. package/src/lab/Workspace.surface.test.tsx +49 -0
  53. package/src/lab/Workspace.tsx +14 -1
  54. package/src/state/store.ts +8 -2
  55. package/src/state/types.ts +4 -2
  56. package/src/state/view.test.ts +127 -0
  57. package/src/state/view.ts +18 -0
  58. package/src/surface/AGENTS.md +64 -0
  59. package/src/surface/SurfaceContext.ts +5 -0
  60. package/src/surface/composeRects.test.ts +50 -0
  61. package/src/surface/composeRects.ts +19 -0
  62. package/src/surface/deviceRect.test.ts +40 -0
  63. package/src/surface/deviceRect.ts +19 -0
  64. package/src/surface/index.ts +7 -0
  65. package/src/surface/rect.ts +16 -0
  66. package/src/surface/useSurfaceTile.test.tsx +67 -0
  67. package/src/surface/useSurfaceTile.ts +32 -0
  68. package/src/surface/useTiledSurface.test.tsx +231 -0
  69. package/src/surface/useTiledSurface.ts +157 -0
  70. package/src/trial/DefaultStatusBar.tsx +2 -2
  71. package/src/trial/Trial.job.test.tsx +75 -0
  72. package/src/trial/Trial.less +12 -0
  73. package/src/trial/Trial.stories.tsx +8 -1
  74. package/src/trial/Trial.tsx +32 -6
  75. package/src/trial/TrialChrome.tsx +33 -6
  76. package/src/trial/slotTypes.ts +2 -1
  77. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  78. package/dist/chunk-C6GJKPUI.js.map +0 -1
  79. package/dist/chunk-DJLDIRFN.js.map +0 -1
@@ -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
+ }
@@ -33,10 +33,9 @@ export const Default: Story = {
33
33
 
34
34
  function AddSecondTrial() {
35
35
  const ctx = useLabContext();
36
- // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount
37
36
  useEffect(() => {
38
37
  if (ctx.trials.length < 2) ctx.addTrial('Stub');
39
- }, []);
38
+ }, [ctx]);
40
39
  return null;
41
40
  }
42
41
 
@@ -0,0 +1,49 @@
1
+ import { render } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { SurfaceContext } from '../surface/SurfaceContext';
4
+ import type { SurfaceHandle } from '../surface/useTiledSurface';
5
+ import { Workspace } from './Workspace';
6
+
7
+ function fakeHandle(): SurfaceHandle {
8
+ return {
9
+ invalidate: vi.fn(),
10
+ invalidateAll: vi.fn(),
11
+ invalidateRects: vi.fn(),
12
+ registerTile: vi.fn(),
13
+ containerRef: vi.fn(),
14
+ };
15
+ }
16
+
17
+ describe('Workspace with a surface above it', () => {
18
+ it('invalidates rects when the tile set changes, because a re-tile moves tiles', () => {
19
+ const handle = fakeHandle();
20
+ const { rerender } = render(
21
+ <SurfaceContext.Provider value={handle}>
22
+ <Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
23
+ <div>a</div>
24
+ </Workspace>
25
+ </SurfaceContext.Provider>,
26
+ );
27
+ (handle.invalidateRects as ReturnType<typeof vi.fn>).mockClear();
28
+
29
+ rerender(
30
+ <SurfaceContext.Provider value={handle}>
31
+ <Workspace ids={['a', 'b']} viewport={{ w: 800, h: 600 }}>
32
+ <div>a</div>
33
+ <div>b</div>
34
+ </Workspace>
35
+ </SurfaceContext.Provider>,
36
+ );
37
+
38
+ expect(handle.invalidateRects).toHaveBeenCalled();
39
+ });
40
+
41
+ it('renders unchanged with no surface above it', () => {
42
+ const { getByText } = render(
43
+ <Workspace ids={['a']} viewport={{ w: 800, h: 600 }}>
44
+ <div>a</div>
45
+ </Workspace>,
46
+ );
47
+ expect(getByText('a')).toBeInTheDocument();
48
+ });
49
+ });
@@ -17,6 +17,8 @@ import {
17
17
  StrategyRegistryProvider,
18
18
  } from 'windease/react';
19
19
 
20
+ import { useSurfaceOptional } from '../surface/useSurfaceTile';
21
+
20
22
  const ZONE_ID = asNodeId('lk-workspace');
21
23
  const STRATEGIES = { grid: gridStrategy as never };
22
24
  const KIND = 'trial';
@@ -93,7 +95,7 @@ export function Workspace({
93
95
  const items = Children.toArray(children);
94
96
  const idKey = ids ? ids.join(',') : `#${items.length}`;
95
97
  // biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
96
- const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
98
+ const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]); // eslint-disable-line react-hooks/exhaustive-deps
97
99
 
98
100
  // Held in refs rather than depended on: a fresh object each render would
99
101
  // re-run the sync effect, and only a newly registered tile reads `layout`.
@@ -119,6 +121,10 @@ export function Workspace({
119
121
  }
120
122
  const store = storeRef.current;
121
123
 
124
+ // A tile that only moves reports nothing to a ResizeObserver, and only this
125
+ // component knows the grid moved one. Optional: a lab may own no surface.
126
+ const surface = useSurfaceOptional();
127
+
122
128
  useLayoutEffect(() => {
123
129
  store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
124
130
  }, [store, resizable, gap, padding]);
@@ -139,6 +145,13 @@ export function Workspace({
139
145
  store.setChildOrder(ZONE_ID, [...nodeIds]);
140
146
  }, [store, nodeIds]);
141
147
 
148
+ // biome-ignore lint/correctness/useExhaustiveDependencies: nodeIds is the signal that the tile set changed — a re-tile moves tiles without resizing any — not a value this reads
149
+ useEffect(() => {
150
+ if (!surface) return;
151
+ surface.invalidateRects();
152
+ return store.events.on('node.placementChanged', () => surface.invalidateRects());
153
+ }, [store, surface, nodeIds]); // eslint-disable-line react-hooks/exhaustive-deps
154
+
142
155
  useEffect(() => {
143
156
  if (!onLayoutChange) return;
144
157
  return store.events.on('node.placementChanged', () => {
@@ -28,7 +28,7 @@ export interface LabStoreActions {
28
28
  removeTrial: (id: string) => void;
29
29
  updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
30
30
  updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
31
- updateTrialView: (id: string, view: TrialRecord['view']) => void;
31
+ updateTrialView: (id: string, view: unknown) => void;
32
32
  updateTrialUndoStack: (
33
33
  id: string,
34
34
  next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
@@ -89,6 +89,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
89
89
  if (w.id !== id) return w;
90
90
  const nextState =
91
91
  typeof next === 'function' ? (next as (prev: unknown) => unknown)(w.state) : next;
92
+ // An updater that returns its input means "nothing changed", and must not
93
+ // cost a new record: the trial re-renders on record identity, so
94
+ // allocating here turns the standard React bail-out into a render loop.
95
+ if (Object.is(nextState, w.state)) return w;
92
96
  return { ...w, state: nextState };
93
97
  }),
94
98
  }));
@@ -110,7 +114,9 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
110
114
 
111
115
  updateTrialView: (id, view) => {
112
116
  set((s) => ({
113
- trials: s.trials.map((w) => (w.id === id ? { ...w, view } : w)),
117
+ trials: s.trials.map((w) =>
118
+ w.id === id && !Object.is(view, w.view) ? { ...w, view } : w,
119
+ ),
114
120
  }));
115
121
  scheduleFlush();
116
122
  },
@@ -7,12 +7,14 @@ export interface UndoStack {
7
7
 
8
8
  /** One trial as the store holds it: which instrument it runs, that
9
9
  * instrument's config and state, the camera, and the undo history. */
10
- export interface TrialRecord<TS = unknown, TC = unknown> {
10
+ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
11
11
  id: string;
12
12
  instrumentName: string;
13
13
  config: TC;
14
14
  state: TS;
15
- view: { zoom: number; pan: { x: number; y: number } };
15
+ /** Opaque to labkit: persisted, restored on Reset and handed to the instrument,
16
+ * but never read into. A 3D lab puts an orbit here and keeps all three. */
17
+ view: TV;
16
18
  undoStack: UndoStack;
17
19
  }
18
20
 
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createMemoryAdapter } from './adapters';
3
+ import { createLabStore } from './store';
4
+ import { as2DView, DEFAULT_VIEW } from './view';
5
+
6
+ interface OrbitView {
7
+ yaw: number;
8
+ pitch: number;
9
+ distance: number;
10
+ }
11
+
12
+ const orbit: OrbitView = { yaw: 1.1, pitch: 0.3, distance: 9 };
13
+
14
+ describe('as2DView', () => {
15
+ it('accepts the 2D shape', () => {
16
+ expect(as2DView({ zoom: 2, pan: { x: 1, y: 3 } })).toEqual({ zoom: 2, pan: { x: 1, y: 3 } });
17
+ });
18
+
19
+ it('copies rather than aliasing, so a caller cannot mutate the record', () => {
20
+ const source = { zoom: 2, pan: { x: 1, y: 3 } };
21
+ const out = as2DView(source);
22
+ expect(out).not.toBe(source);
23
+ expect(out?.pan).not.toBe(source.pan);
24
+ });
25
+
26
+ it('rejects an orbit view', () => {
27
+ expect(as2DView(orbit)).toBeNull();
28
+ });
29
+
30
+ it('rejects a partial 2D view rather than filling in a default', () => {
31
+ expect(as2DView({ zoom: 2 })).toBeNull();
32
+ expect(as2DView({ zoom: 2, pan: { x: 1 } })).toBeNull();
33
+ });
34
+
35
+ it('rejects things that are not objects', () => {
36
+ expect(as2DView(null)).toBeNull();
37
+ expect(as2DView(undefined)).toBeNull();
38
+ expect(as2DView(4)).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('a trial view labkit does not interpret', () => {
43
+ it('stores and returns a view shape that is not zoom/pan', () => {
44
+ const store = createLabStore({ storageKey: 'view-a', storage: createMemoryAdapter() });
45
+ store.getState().addTrial({
46
+ id: 'w1',
47
+ instrumentName: 'gem',
48
+ config: {},
49
+ state: {},
50
+ view: DEFAULT_VIEW,
51
+ });
52
+
53
+ store.getState().updateTrialView('w1', orbit);
54
+
55
+ expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
56
+ });
57
+
58
+ it('round-trips that view through persistence', () => {
59
+ vi.useFakeTimers();
60
+ const mem = createMemoryAdapter();
61
+ const seed = createLabStore({ storageKey: 'view-b', storage: mem });
62
+ seed.getState().addTrial({
63
+ id: 'w1',
64
+ instrumentName: 'gem',
65
+ config: {},
66
+ state: {},
67
+ view: orbit,
68
+ });
69
+ vi.advanceTimersByTime(500);
70
+ vi.useRealTimers();
71
+
72
+ const hydrated = createLabStore({ storageKey: 'view-b', storage: mem });
73
+ expect(hydrated.getState().trials[0]?.view as OrbitView).toEqual(orbit);
74
+ });
75
+ });
76
+
77
+ describe('an unchanged write costs no new record', () => {
78
+ it('returns the same trial object when setState returns its input', () => {
79
+ const store = createLabStore({ storageKey: 'identity-a', storage: createMemoryAdapter() });
80
+ store.getState().addTrial({
81
+ id: 'w1',
82
+ instrumentName: 'gem',
83
+ config: {},
84
+ state: { n: 1 },
85
+ view: DEFAULT_VIEW,
86
+ });
87
+ const before = store.getState().trials[0];
88
+
89
+ store.getState().updateTrialState('w1', (prev: unknown) => prev);
90
+
91
+ // A trial re-renders on record identity, so allocating here would turn the
92
+ // standard React bail-out into a render loop.
93
+ expect(store.getState().trials[0]).toBe(before);
94
+ });
95
+
96
+ it('still replaces the record when the state actually changes', () => {
97
+ const store = createLabStore({ storageKey: 'identity-b', storage: createMemoryAdapter() });
98
+ store.getState().addTrial({
99
+ id: 'w1',
100
+ instrumentName: 'gem',
101
+ config: {},
102
+ state: { n: 1 },
103
+ view: DEFAULT_VIEW,
104
+ });
105
+ const before = store.getState().trials[0];
106
+
107
+ store.getState().updateTrialState('w1', { n: 2 });
108
+
109
+ expect(store.getState().trials[0]).not.toBe(before);
110
+ });
111
+
112
+ it('returns the same trial object when the view is written unchanged', () => {
113
+ const store = createLabStore({ storageKey: 'identity-c', storage: createMemoryAdapter() });
114
+ store.getState().addTrial({
115
+ id: 'w1',
116
+ instrumentName: 'gem',
117
+ config: {},
118
+ state: {},
119
+ view: orbit,
120
+ });
121
+ const before = store.getState().trials[0];
122
+
123
+ store.getState().updateTrialView('w1', orbit);
124
+
125
+ expect(store.getState().trials[0]).toBe(before);
126
+ });
127
+ });
@@ -0,0 +1,18 @@
1
+ import type { ViewTransform } from '../instrument/types';
2
+
3
+ /** The 2D view labkit has always shipped, and what a trial gets when it names no
4
+ * other. Re-exported under its own name so a consumer can say which it means. */
5
+ export type ViewTransform2D = ViewTransform;
6
+
7
+ export const DEFAULT_VIEW: ViewTransform2D = { zoom: 1, pan: { x: 0, y: 0 } };
8
+
9
+ /** A trial's view is opaque to labkit, so anything that needs the 2D shape — the
10
+ * zoom chrome, `CanvasStack` — asks for it and handles not getting it. */
11
+ export function as2DView(view: unknown): ViewTransform2D | null {
12
+ if (typeof view !== 'object' || view === null) return null;
13
+ const v = view as Partial<ViewTransform2D>;
14
+ if (typeof v.zoom !== 'number') return null;
15
+ if (typeof v.pan !== 'object' || v.pan === null) return null;
16
+ if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
17
+ return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
18
+ }