Skip to content

Commit 25f23cd

Browse files
committed
feat: implement preview result handling and token management across components
1 parent f941916 commit 25f23cd

15 files changed

Lines changed: 452 additions & 48 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1212
can now render a temporary result while their parameters are edited. The
1313
preview is disabled by default, reuses the already-loaded computation worker
1414
without changing the workspace, supports bounded numeric sliders and
15-
image-profile geometry, and is discarded on Cancel or Escape; OK still
16-
performs the normal processing from the original source.
15+
image-profile geometry, and is discarded on Cancel or Escape. For a single
16+
source, OK reuses a completed current preview once; stale, running and
17+
multi-selection previews fall back to normal processing.
1718

1819
### Changed
1920

‎doc/architecture.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,13 @@ from pickle, add_group, get/set selection, etc.).
275275
`ProcessingPreviewController` coordinates requests per `RuntimeApi`: one
276276
computation may run while a single pending slot retains only the latest
277277
parameters. `bootstrap.preview_feature` deep-copies the source and delegates
278-
to the same `BaseProcessor.apply` path as publication, then returns only a
279-
binary plotting payload. It never inserts the temporary object into `_MODEL`,
280-
updates processing history, or dirties the workspace.
278+
to the same `BaseProcessor.apply` path as publication, then returns a binary
279+
plotting payload. For a single-source dialog it also retains the complete raw
280+
result behind an opaque token. `apply_feature` consumes that result at most once
281+
after checking the feature, source data revision and parameters, then performs
282+
the usual publication and history updates. A missing, stale or mismatched token
283+
falls back to normal computation. Preview never inserts the temporary object
284+
into `_MODEL`, updates processing history, or dirties the workspace.
281285

282286
Disabling preview, Cancel, Escape, or dialog unmount drops pending work and
283287
invalidates late results. A Python call that has already started is allowed to
@@ -484,9 +488,9 @@ compatibility independently of the application version.
484488
3. If params needed → ProcessingDataSetDialog (auto-generated from JSON schema)
485489
4. Optional Preview → runtime.previewFeature(featureId, sourceOid, paramValues)
486490
5. bootstrap deep-copies the source and returns a private plotting payload
487-
6. OK invalidates preview and calls runtime.applyFeature(featureId, selectedOids, paramValues)
488-
7. pyodide.runPython → processor.apply_*(feature, oids, params)
489-
8. Sigima computes; new SignalObj/ImageObj added to _MODEL
491+
6. OK detaches a current single-source preview token, if available, and calls runtime.applyFeature(featureId, selectedOids, paramValues, token)
492+
7. bootstrap consumes a matching result once, or calls processor.apply_*(feature, oids, params)
493+
8. The result is published as a new SignalObj/ImageObj in _MODEL
490494
9. bootstrap returns new oids; UI refreshes ObjectTree + plots
491495
10. WorkspaceContext is marked dirty
492496
```

‎doc/userguide/computation-engine.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@ batch operation.
4848

4949
**Cancel** and **Esc** discard the temporary result and any waiting refresh.
5050
An already-running computation finishes in the background and its late result
51-
is ignored. **OK** runs the normal processing path from the original source
52-
and current parameters. Preview is hidden in the `runtime=main` fallback to
53-
avoid running this interactive work on the UI thread. The Processing tab in
54-
the object side panel retains its explicit **Apply** and **Reset** controls;
55-
editing that tab never republishes a result automatically.
51+
is ignored. With one source, **OK** consumes a completed, up-to-date preview
52+
once and publishes it through the normal processing path. A running or stale
53+
preview, a changed source, and multi-object or group selections fall back to
54+
normal computation from the original source and current parameters. Preview is
55+
hidden in the `runtime=main` fallback to avoid running this interactive work on
56+
the UI thread. The Processing tab in the object side panel retains its explicit
57+
**Apply** and **Reset** controls; editing that tab never republishes a result
58+
automatically.
5659

5760
## What the desktop docs do **not** cover
5861

‎src/App.tsx‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,8 +1511,16 @@ export default function App() {
15111511
operandId: string | null,
15121512
values: Record<string, unknown> | null,
15131513
groupIds: string[] = [],
1514+
previewToken: string | null = null,
15141515
) => {
15151516
if (!runtime) return;
1517+
const reusablePreviewToken =
1518+
previewToken !== null &&
1519+
feature.pattern === "1_to_1" &&
1520+
sourceIds.length === 1 &&
1521+
groupIds.length === 0
1522+
? previewToken
1523+
: null;
15161524
setBusy(true);
15171525
try {
15181526
// Cooperative cancellation: when a 1-to-1 / 2-to-1 feature is applied
@@ -1541,6 +1549,7 @@ export default function App() {
15411549
operandId,
15421550
values,
15431551
[],
1552+
null,
15441553
),
15451554
});
15461555
newIds = results.flat();
@@ -1564,6 +1573,7 @@ export default function App() {
15641573
operandId,
15651574
values,
15661575
groupIds,
1576+
reusablePreviewToken,
15671577
),
15681578
});
15691579
newIds = results[0] ?? [];
@@ -1589,6 +1599,11 @@ export default function App() {
15891599
traceback: err instanceof Error ? err.message : String(err),
15901600
});
15911601
} finally {
1602+
if (previewToken !== null) {
1603+
await runtime
1604+
.releasePreviewResult(previewToken)
1605+
.catch(() => undefined);
1606+
}
15921607
setBusy(false);
15931608
}
15941609
},
@@ -1844,21 +1859,41 @@ export default function App() {
18441859
);
18451860

18461861
const handleSubmitParams = useCallback(
1847-
async (values: Record<string, unknown>) => {
1862+
async (
1863+
values: Record<string, unknown>,
1864+
previewToken: string | null = null,
1865+
) => {
18481866
if (!pending) return;
18491867
const { feature, sourceIds, operandId, groupIds } = pending;
18501868
setPending(null);
1851-
await runFeature(feature, sourceIds, operandId, values, groupIds);
1869+
await runFeature(
1870+
feature,
1871+
sourceIds,
1872+
operandId,
1873+
values,
1874+
groupIds,
1875+
previewToken,
1876+
);
18521877
},
18531878
[pending, runFeature],
18541879
);
18551880

18561881
const handleSubmitProfile = useCallback(
1857-
async (values: Record<string, unknown>) => {
1882+
async (
1883+
values: Record<string, unknown>,
1884+
previewToken: string | null = null,
1885+
) => {
18581886
if (!pendingProfile) return;
18591887
const { feature, sourceIds, groupIds } = pendingProfile;
18601888
setPendingProfile(null);
1861-
await runFeature(feature, sourceIds, null, values, groupIds);
1889+
await runFeature(
1890+
feature,
1891+
sourceIds,
1892+
null,
1893+
values,
1894+
groupIds,
1895+
previewToken,
1896+
);
18621897
},
18631898
[pendingProfile, runFeature],
18641899
);

‎src/components/ProcessingDataSetDialog.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ interface Props {
3232
resolveActive?: (
3333
currentValues: Record<string, unknown>,
3434
) => Promise<Record<string, boolean>>;
35-
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
35+
onSubmit: (
36+
values: Record<string, unknown>,
37+
previewToken?: string | null,
38+
) => void | Promise<void>;
3639
onCancel: () => void;
3740
}
3841

@@ -64,11 +67,12 @@ export function ProcessingDataSetDialog(props: Props) {
6467

6568
const submit = async () => {
6669
if (!formState.valid || formState.resolving) return;
70+
const previewToken = previewRef.current?.takeCurrentResult() ?? null;
6771
previewRef.current?.stop();
6872
setError(null);
6973
setSubmitting(true);
7074
try {
71-
await onSubmit(values);
75+
await onSubmit(values, previewToken);
7276
} catch (caught) {
7377
setError(caught instanceof Error ? caught.message : String(caught));
7478
} finally {

‎src/components/ProcessingPreview.tsx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface Props {
3838

3939
export interface ProcessingPreviewHandle {
4040
stop: () => void;
41+
takeCurrentResult: () => string | null;
4142
}
4243

4344
interface SourceOption {
@@ -91,7 +92,15 @@ export const ProcessingPreview = forwardRef<ProcessingPreviewHandle, Props>(
9192
controller.close();
9293
}, [controller]);
9394

94-
useImperativeHandle(ref, () => ({ stop }), [stop]);
95+
const takeCurrentResult = useCallback(
96+
() => controller.takeCurrentResult(),
97+
[controller],
98+
);
99+
100+
useImperativeHandle(ref, () => ({ stop, takeCurrentResult }), [
101+
stop,
102+
takeCurrentResult,
103+
]);
95104

96105
useEffect(() => {
97106
let cancelled = false;
@@ -177,8 +186,17 @@ export const ProcessingPreview = forwardRef<ProcessingPreviewHandle, Props>(
177186
featureId: feature.id,
178187
sourceId,
179188
params: values,
189+
reuseResult: sourceIds.length === 1,
180190
});
181-
}, [controller, feature.id, resolving, sourceId, valid, values]);
191+
}, [
192+
controller,
193+
feature.id,
194+
resolving,
195+
sourceId,
196+
sourceIds.length,
197+
valid,
198+
values,
199+
]);
182200

183201
useLayoutEffect(() => {
184202
requestPreviewRef.current = requestPreview;

‎src/components/ProfileDefinitionDialog.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ interface Props {
6464
resolveActive?: (
6565
currentValues: Record<string, unknown>,
6666
) => Promise<Record<string, boolean>>;
67-
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
67+
onSubmit: (
68+
values: Record<string, unknown>,
69+
previewToken?: string | null,
70+
) => void | Promise<void>;
6871
onCancel: () => void;
6972
}
7073

@@ -265,11 +268,12 @@ export function ProfileDefinitionDialog(props: Props) {
265268

266269
const submit = async () => {
267270
if (!formState.valid || formState.resolving) return;
271+
const previewToken = previewRef.current?.takeCurrentResult() ?? null;
268272
previewRef.current?.stop();
269273
setError(null);
270274
setSubmitting(true);
271275
try {
272-
await onSubmit(values);
276+
await onSubmit(values, previewToken);
273277
} catch (err) {
274278
setError(err instanceof Error ? err.message : String(err));
275279
} finally {

‎src/runtime/ProcessingPreviewController.ts‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface ProcessingPreviewRequest {
44
featureId: string;
55
sourceId: string;
66
params: Record<string, unknown>;
7+
reuseResult?: boolean;
78
}
89

910
export type ProcessingPreviewState =
@@ -19,6 +20,13 @@ interface QueuedRequest extends ProcessingPreviewRequest {
1920
id: number;
2021
epoch: number;
2122
revision: number;
23+
previewToken: string | null;
24+
}
25+
26+
let nextPreviewToken = 1;
27+
28+
function createPreviewToken(): string {
29+
return `preview-${Date.now().toString(36)}-${nextPreviewToken++}`;
2230
}
2331

2432
function cloneParams(values: Record<string, unknown>): Record<string, unknown> {
@@ -80,11 +88,18 @@ export class ProcessingPreviewController {
8088
private nextId = 1;
8189
private epoch = 0;
8290
private revision = 0;
91+
private currentResult: Pick<
92+
QueuedRequest,
93+
"previewToken" | "epoch" | "revision"
94+
> | null = null;
8395
private readonly coordinator: ProcessingPreviewCoordinator;
8496

8597
constructor(
8698
private readonly onState: (state: ProcessingPreviewState) => void,
87-
private readonly runtime: Pick<RuntimeApi, "previewFeature">,
99+
private readonly runtime: Pick<
100+
RuntimeApi,
101+
"previewFeature" | "releasePreviewResult"
102+
>,
88103
) {
89104
this.coordinator = coordinatorFor(runtime as RuntimeApi);
90105
}
@@ -99,11 +114,13 @@ export class ProcessingPreviewController {
99114
}
100115

101116
markDirty(): void {
117+
this.releaseCurrentResult();
102118
this.revision += 1;
103119
this.coordinator.cancelPending(this);
104120
}
105121

106122
invalidate(): void {
123+
this.releaseCurrentResult();
107124
this.epoch += 1;
108125
this.revision += 1;
109126
this.coordinator.cancelPending(this);
@@ -115,6 +132,7 @@ export class ProcessingPreviewController {
115132

116133
request(request: ProcessingPreviewRequest): void {
117134
if (!this.enabled) return;
135+
this.releaseCurrentResult();
118136
if (this.targetSourceId !== request.sourceId) {
119137
this.epoch += 1;
120138
this.targetSourceId = request.sourceId;
@@ -126,6 +144,7 @@ export class ProcessingPreviewController {
126144
id: this.nextId++,
127145
epoch: this.epoch,
128146
revision: this.revision,
147+
previewToken: request.reuseResult === false ? null : createPreviewToken(),
129148
};
130149
this.onState({ status: "computing" });
131150
this.coordinator.submit({
@@ -146,15 +165,26 @@ export class ProcessingPreviewController {
146165
request.featureId,
147166
request.sourceId,
148167
request.params,
168+
request.previewToken,
149169
);
150170
const sameEpoch = this.enabled && request.epoch === this.epoch;
151-
if (!sameEpoch) return;
171+
if (!sameEpoch) {
172+
this.releaseToken(request.previewToken);
173+
return;
174+
}
175+
const current = request.revision === this.revision;
176+
if (current && request.previewToken !== null) {
177+
this.currentResult = request;
178+
} else {
179+
this.releaseToken(request.previewToken);
180+
}
152181
this.onState({
153182
status: "result",
154183
result,
155-
current: request.revision === this.revision,
184+
current,
156185
});
157186
} catch (error) {
187+
this.releaseToken(request.previewToken);
158188
const current =
159189
this.enabled &&
160190
request.epoch === this.epoch &&
@@ -177,9 +207,36 @@ export class ProcessingPreviewController {
177207
}
178208

179209
private invalidateSession(): void {
210+
this.releaseCurrentResult();
180211
this.epoch += 1;
181212
this.revision += 1;
182213
this.targetSourceId = null;
183214
this.coordinator.cancelPending(this);
184215
}
216+
217+
takeCurrentResult(): string | null {
218+
const candidate = this.currentResult;
219+
this.currentResult = null;
220+
if (
221+
!this.enabled ||
222+
candidate === null ||
223+
candidate.epoch !== this.epoch ||
224+
candidate.revision !== this.revision
225+
) {
226+
this.releaseToken(candidate?.previewToken ?? null);
227+
return null;
228+
}
229+
return candidate.previewToken;
230+
}
231+
232+
private releaseCurrentResult(): void {
233+
const token = this.currentResult?.previewToken ?? null;
234+
this.currentResult = null;
235+
this.releaseToken(token);
236+
}
237+
238+
private releaseToken(token: string | null): void {
239+
if (token === null) return;
240+
void this.runtime.releasePreviewResult(token).catch(() => undefined);
241+
}
185242
}

0 commit comments

Comments
 (0)