@selvajs/ui 5.0.0-beta.1 → 5.0.0-beta.2
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/components/viewer/SceneManager.svelte +23 -19
- package/dist/components/viewer/Viewer.svelte +10 -2
- package/dist/compute/computeThrottle.svelte.js +10 -1
- package/dist/compute/createSolveSession.svelte.js +10 -2
- package/dist/compute/solveMemo.js +7 -0
- package/package.json +1 -1
- package/src/lib/components/viewer/SceneManager.svelte +23 -19
- package/src/lib/components/viewer/Viewer.svelte +10 -2
- package/src/lib/compute/computeThrottle.svelte.ts +10 -1
- package/src/lib/compute/createSolveSession.svelte.ts +12 -2
- package/src/lib/compute/solveMemo.ts +7 -0
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
19
19
|
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Content objects of the scene, recomputed only when a solve bumps `sceneVersion` (scene.children is
|
|
22
|
+
// a plain array the library mutates in place, so that counter is the sole reactive trigger). Derived
|
|
23
|
+
// — not a function — so the several template sites that read it share one walk per solve instead of
|
|
24
|
+
// re-filtering scene.children on every render.
|
|
25
|
+
const sceneObjects = $derived.by(() => {
|
|
22
26
|
void sceneVersion;
|
|
23
27
|
return scene.children.filter(
|
|
24
28
|
(obj) =>
|
|
@@ -26,20 +30,19 @@
|
|
|
26
30
|
!(obj instanceof THREE.Light) &&
|
|
27
31
|
!HELPER_IDS.has(obj.userData?.id)
|
|
28
32
|
);
|
|
29
|
-
};
|
|
33
|
+
});
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Content grouped by layer. Derived from `sceneObjects`, so the grouping map is rebuilt once per
|
|
36
|
+
// solve rather than every time the list renders.
|
|
37
|
+
const layerGroups = $derived.by(() => {
|
|
33
38
|
const groups = new SvelteMap<string, THREE.Object3D[]>();
|
|
34
|
-
|
|
35
|
-
for (const obj of objects) {
|
|
39
|
+
for (const obj of sceneObjects) {
|
|
36
40
|
const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
|
|
37
41
|
if (!groups.has(layer)) groups.set(layer, []);
|
|
38
42
|
groups.get(layer)!.push(obj);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
44
|
return groups;
|
|
42
|
-
};
|
|
45
|
+
});
|
|
43
46
|
|
|
44
47
|
let hiddenUuids = new SvelteSet<string>();
|
|
45
48
|
|
|
@@ -59,8 +62,7 @@
|
|
|
59
62
|
|
|
60
63
|
const toggleObject = (object: THREE.Object3D) => {
|
|
61
64
|
if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
|
|
62
|
-
const
|
|
63
|
-
const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
65
|
+
const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
64
66
|
const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
|
|
65
67
|
for (const o of selected) setObjectVisible(o, allHidden);
|
|
66
68
|
} else {
|
|
@@ -104,23 +106,25 @@
|
|
|
104
106
|
|
|
105
107
|
let searchQuery = $state('');
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// Layer groups after the search filter. Derived from `layerGroups` + `searchQuery`, so filtering
|
|
110
|
+
// runs once per search keystroke — not twice per render (it's read by both the list and the
|
|
111
|
+
// empty-state check below).
|
|
112
|
+
const filteredLayerGroups = $derived.by(() => {
|
|
113
|
+
if (!searchQuery.trim()) return layerGroups;
|
|
110
114
|
const q = searchQuery.toLowerCase();
|
|
111
115
|
const filtered = new SvelteMap<string, THREE.Object3D[]>();
|
|
112
|
-
for (const [layerName, objects] of
|
|
116
|
+
for (const [layerName, objects] of layerGroups) {
|
|
113
117
|
const matchingObjects = layerName.toLowerCase().includes(q)
|
|
114
118
|
? objects
|
|
115
119
|
: objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
|
|
116
120
|
if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
|
|
117
121
|
}
|
|
118
122
|
return filtered;
|
|
119
|
-
};
|
|
123
|
+
});
|
|
120
124
|
|
|
121
125
|
const getFlatVisibleUuids = (): string[] => {
|
|
122
126
|
const result: string[] = [];
|
|
123
|
-
for (const [layerName, objects] of
|
|
127
|
+
for (const [layerName, objects] of filteredLayerGroups) {
|
|
124
128
|
if (!collapsedLayers.has(layerName)) {
|
|
125
129
|
for (const obj of objects) result.push(obj.uuid);
|
|
126
130
|
}
|
|
@@ -182,7 +186,7 @@
|
|
|
182
186
|
</div>
|
|
183
187
|
|
|
184
188
|
<div class="py-1 flex-1 overflow-y-auto">
|
|
185
|
-
{#each [...
|
|
189
|
+
{#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
|
|
186
190
|
{@const layerHidden = isLayerHidden(objects)}
|
|
187
191
|
{@const layerPartial = isLayerPartial(objects)}
|
|
188
192
|
{@const collapsed = collapsedLayers.has(layerName)}
|
|
@@ -285,12 +289,12 @@
|
|
|
285
289
|
{/each}
|
|
286
290
|
|
|
287
291
|
<!-- Empty state -->
|
|
288
|
-
{#if
|
|
292
|
+
{#if sceneObjects.length === 0}
|
|
289
293
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
290
294
|
<EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
291
295
|
<p class="text-xs text-muted-foreground">{t.noObjects}</p>
|
|
292
296
|
</div>
|
|
293
|
-
{:else if
|
|
297
|
+
{:else if filteredLayerGroups.size === 0}
|
|
294
298
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
295
299
|
<Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
296
300
|
<p class="text-xs text-muted-foreground">
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
let cameraController: CameraController | null = null;
|
|
108
108
|
let measureTool: MeasureTool | null = null;
|
|
109
109
|
let grid: Grid | null = null;
|
|
110
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
110
111
|
let fitToView: (() => void) | null = null;
|
|
111
112
|
let viewerInitialized = false;
|
|
112
113
|
let sceneVersion = $state(0);
|
|
@@ -143,12 +144,15 @@
|
|
|
143
144
|
onMount(() => {
|
|
144
145
|
if (!canvas) return;
|
|
145
146
|
|
|
147
|
+
// Only options that differ from the library defaults. The default look is already 'technical'
|
|
148
|
+
// (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
|
|
149
|
+
// sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
|
|
146
150
|
const opts: ThreeInitializerOptions = {
|
|
151
|
+
lighting: { enableSunlight: false },
|
|
152
|
+
render: { enableShadows: false },
|
|
147
153
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
154
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
155
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
156
|
measure: { enabled: config.showToolsMenu },
|
|
153
157
|
events: {
|
|
154
158
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,6 +174,7 @@
|
|
|
170
174
|
measureTool = init.measureTool;
|
|
171
175
|
grid = init.grid;
|
|
172
176
|
grid?.setVisible(gridVisible);
|
|
177
|
+
applyEdges = init.applyEdges;
|
|
173
178
|
fitToView = init.fitToView;
|
|
174
179
|
projection = init.cameraController.getProjection();
|
|
175
180
|
|
|
@@ -208,6 +213,9 @@
|
|
|
208
213
|
$effect(() => {
|
|
209
214
|
if (scene && camera && controls) {
|
|
210
215
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
216
|
+
// Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
|
|
217
|
+
// each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
|
|
218
|
+
applyEdges?.(scene);
|
|
211
219
|
untrack(() => sceneVersion++);
|
|
212
220
|
|
|
213
221
|
if (!viewerInitialized && meshes.length > 0) {
|
|
@@ -24,7 +24,12 @@ export function createComputeThrottle(computeFn, options = {}) {
|
|
|
24
24
|
abortCurrent();
|
|
25
25
|
currentAbortController = new AbortController();
|
|
26
26
|
const { signal } = currentAbortController;
|
|
27
|
-
const timeoutId = setTimeout(() =>
|
|
27
|
+
const timeoutId = setTimeout(() => {
|
|
28
|
+
// Cleared in `finally` on every other path, so firing means a genuine
|
|
29
|
+
// timeout — the only signal for it (the abort itself is swallowed below).
|
|
30
|
+
console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
|
|
31
|
+
currentAbortController?.abort();
|
|
32
|
+
}, timeout);
|
|
28
33
|
isComputing = true;
|
|
29
34
|
try {
|
|
30
35
|
await computeFn(values, signal);
|
|
@@ -50,6 +55,10 @@ export function createComputeThrottle(computeFn, options = {}) {
|
|
|
50
55
|
}
|
|
51
56
|
function trigger(values) {
|
|
52
57
|
if (isComputing) {
|
|
58
|
+
if (pendingValues !== null) {
|
|
59
|
+
// Latest-wins: the previously-queued values are dropped, not solved.
|
|
60
|
+
console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
|
|
61
|
+
}
|
|
53
62
|
pendingValues = values;
|
|
54
63
|
}
|
|
55
64
|
else {
|
|
@@ -123,14 +123,22 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
|
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
125
|
const result = await onSolve(values, signal);
|
|
126
|
-
if (signal.aborted)
|
|
126
|
+
if (signal.aborted) {
|
|
127
|
+
// Discarded on purpose (superseded/cancelled) — never memoized or reported.
|
|
128
|
+
console.debug('[Compute/session] solve completed after abort — result discarded');
|
|
127
129
|
return;
|
|
130
|
+
}
|
|
128
131
|
memo.set(values, result);
|
|
129
132
|
getReporter().report(result);
|
|
130
133
|
}
|
|
131
134
|
catch (err) {
|
|
132
|
-
if (signal.aborted)
|
|
135
|
+
if (signal.aborted) {
|
|
136
|
+
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
133
137
|
return;
|
|
138
|
+
}
|
|
139
|
+
// reportError only sets reactive state; without this line a transport
|
|
140
|
+
// failure leaves no console trace at all.
|
|
141
|
+
console.warn('[Compute/session] solve failed:', err);
|
|
134
142
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
135
143
|
}
|
|
136
144
|
}, options);
|
|
@@ -38,6 +38,9 @@ export function createSolveMemo(max = 16) {
|
|
|
38
38
|
// Refresh recency: re-insert at the tail.
|
|
39
39
|
entries.delete(key);
|
|
40
40
|
entries.set(key, hit);
|
|
41
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
42
|
+
// this debug line is the only trace it wasn't a fresh solve.
|
|
43
|
+
console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
41
44
|
return hit;
|
|
42
45
|
},
|
|
43
46
|
set(values, result) {
|
|
@@ -49,9 +52,13 @@ export function createSolveMemo(max = 16) {
|
|
|
49
52
|
if (oldest === undefined)
|
|
50
53
|
break;
|
|
51
54
|
entries.delete(oldest);
|
|
55
|
+
console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
52
56
|
}
|
|
53
57
|
},
|
|
54
58
|
clear() {
|
|
59
|
+
if (entries.size > 0) {
|
|
60
|
+
console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
61
|
+
}
|
|
55
62
|
entries.clear();
|
|
56
63
|
}
|
|
57
64
|
};
|
package/package.json
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
// @selvajs/compute. They aren't scene content, so they're hidden from the object list.
|
|
19
19
|
const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Content objects of the scene, recomputed only when a solve bumps `sceneVersion` (scene.children is
|
|
22
|
+
// a plain array the library mutates in place, so that counter is the sole reactive trigger). Derived
|
|
23
|
+
// — not a function — so the several template sites that read it share one walk per solve instead of
|
|
24
|
+
// re-filtering scene.children on every render.
|
|
25
|
+
const sceneObjects = $derived.by(() => {
|
|
22
26
|
void sceneVersion;
|
|
23
27
|
return scene.children.filter(
|
|
24
28
|
(obj) =>
|
|
@@ -26,20 +30,19 @@
|
|
|
26
30
|
!(obj instanceof THREE.Light) &&
|
|
27
31
|
!HELPER_IDS.has(obj.userData?.id)
|
|
28
32
|
);
|
|
29
|
-
};
|
|
33
|
+
});
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Content grouped by layer. Derived from `sceneObjects`, so the grouping map is rebuilt once per
|
|
36
|
+
// solve rather than every time the list renders.
|
|
37
|
+
const layerGroups = $derived.by(() => {
|
|
33
38
|
const groups = new SvelteMap<string, THREE.Object3D[]>();
|
|
34
|
-
|
|
35
|
-
for (const obj of objects) {
|
|
39
|
+
for (const obj of sceneObjects) {
|
|
36
40
|
const layer: string = obj.userData?.layer || obj.userData?.category || 'Default';
|
|
37
41
|
if (!groups.has(layer)) groups.set(layer, []);
|
|
38
42
|
groups.get(layer)!.push(obj);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
44
|
return groups;
|
|
42
|
-
};
|
|
45
|
+
});
|
|
43
46
|
|
|
44
47
|
let hiddenUuids = new SvelteSet<string>();
|
|
45
48
|
|
|
@@ -59,8 +62,7 @@
|
|
|
59
62
|
|
|
60
63
|
const toggleObject = (object: THREE.Object3D) => {
|
|
61
64
|
if (selectedUuids.has(object.uuid) && selectedUuids.size > 1) {
|
|
62
|
-
const
|
|
63
|
-
const selected = allObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
65
|
+
const selected = sceneObjects.filter((o) => selectedUuids.has(o.uuid));
|
|
64
66
|
const allHidden = selected.every((o) => hiddenUuids.has(o.uuid));
|
|
65
67
|
for (const o of selected) setObjectVisible(o, allHidden);
|
|
66
68
|
} else {
|
|
@@ -104,23 +106,25 @@
|
|
|
104
106
|
|
|
105
107
|
let searchQuery = $state('');
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// Layer groups after the search filter. Derived from `layerGroups` + `searchQuery`, so filtering
|
|
110
|
+
// runs once per search keystroke — not twice per render (it's read by both the list and the
|
|
111
|
+
// empty-state check below).
|
|
112
|
+
const filteredLayerGroups = $derived.by(() => {
|
|
113
|
+
if (!searchQuery.trim()) return layerGroups;
|
|
110
114
|
const q = searchQuery.toLowerCase();
|
|
111
115
|
const filtered = new SvelteMap<string, THREE.Object3D[]>();
|
|
112
|
-
for (const [layerName, objects] of
|
|
116
|
+
for (const [layerName, objects] of layerGroups) {
|
|
113
117
|
const matchingObjects = layerName.toLowerCase().includes(q)
|
|
114
118
|
? objects
|
|
115
119
|
: objects.filter((obj) => getObjectLabel(obj).toLowerCase().includes(q));
|
|
116
120
|
if (matchingObjects.length > 0) filtered.set(layerName, matchingObjects);
|
|
117
121
|
}
|
|
118
122
|
return filtered;
|
|
119
|
-
};
|
|
123
|
+
});
|
|
120
124
|
|
|
121
125
|
const getFlatVisibleUuids = (): string[] => {
|
|
122
126
|
const result: string[] = [];
|
|
123
|
-
for (const [layerName, objects] of
|
|
127
|
+
for (const [layerName, objects] of filteredLayerGroups) {
|
|
124
128
|
if (!collapsedLayers.has(layerName)) {
|
|
125
129
|
for (const obj of objects) result.push(obj.uuid);
|
|
126
130
|
}
|
|
@@ -182,7 +186,7 @@
|
|
|
182
186
|
</div>
|
|
183
187
|
|
|
184
188
|
<div class="py-1 flex-1 overflow-y-auto">
|
|
185
|
-
{#each [...
|
|
189
|
+
{#each [...filteredLayerGroups] as [layerName, objects] (layerName)}
|
|
186
190
|
{@const layerHidden = isLayerHidden(objects)}
|
|
187
191
|
{@const layerPartial = isLayerPartial(objects)}
|
|
188
192
|
{@const collapsed = collapsedLayers.has(layerName)}
|
|
@@ -285,12 +289,12 @@
|
|
|
285
289
|
{/each}
|
|
286
290
|
|
|
287
291
|
<!-- Empty state -->
|
|
288
|
-
{#if
|
|
292
|
+
{#if sceneObjects.length === 0}
|
|
289
293
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
290
294
|
<EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
291
295
|
<p class="text-xs text-muted-foreground">{t.noObjects}</p>
|
|
292
296
|
</div>
|
|
293
|
-
{:else if
|
|
297
|
+
{:else if filteredLayerGroups.size === 0}
|
|
294
298
|
<div class="py-12 flex flex-col items-center justify-center text-center">
|
|
295
299
|
<Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
|
|
296
300
|
<p class="text-xs text-muted-foreground">
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
let cameraController: CameraController | null = null;
|
|
108
108
|
let measureTool: MeasureTool | null = null;
|
|
109
109
|
let grid: Grid | null = null;
|
|
110
|
+
let applyEdges: ((root: THREE.Object3D) => void) | null = null;
|
|
110
111
|
let fitToView: (() => void) | null = null;
|
|
111
112
|
let viewerInitialized = false;
|
|
112
113
|
let sceneVersion = $state(0);
|
|
@@ -143,12 +144,15 @@
|
|
|
143
144
|
onMount(() => {
|
|
144
145
|
if (!canvas) return;
|
|
145
146
|
|
|
147
|
+
// Only options that differ from the library defaults. The default look is already 'technical'
|
|
148
|
+
// (flat ambient + HDR image-based lighting; baseHDR loads by default), so we just switch off the
|
|
149
|
+
// sun and shadows it doesn't need — and turn on the grid/measure/edges/click this viewer uses.
|
|
146
150
|
const opts: ThreeInitializerOptions = {
|
|
151
|
+
lighting: { enableSunlight: false },
|
|
152
|
+
render: { enableShadows: false },
|
|
147
153
|
environment: { backgroundColor: config.backgroundColor },
|
|
148
|
-
controls: {},
|
|
149
154
|
// Build the grid so it can be toggled at runtime, but start hidden (off by default).
|
|
150
155
|
grid: { enabled: config.showToolsMenu && config.showGridToggle },
|
|
151
|
-
gizmo: { enabled: false },
|
|
152
156
|
measure: { enabled: config.showToolsMenu },
|
|
153
157
|
events: {
|
|
154
158
|
onMeshMetadataClicked: config.enableMeshClick
|
|
@@ -170,6 +174,7 @@
|
|
|
170
174
|
measureTool = init.measureTool;
|
|
171
175
|
grid = init.grid;
|
|
172
176
|
grid?.setVisible(gridVisible);
|
|
177
|
+
applyEdges = init.applyEdges;
|
|
173
178
|
fitToView = init.fitToView;
|
|
174
179
|
projection = init.cameraController.getProjection();
|
|
175
180
|
|
|
@@ -208,6 +213,9 @@
|
|
|
208
213
|
$effect(() => {
|
|
209
214
|
if (scene && camera && controls) {
|
|
210
215
|
updateScene(scene, meshes, camera, controls, viewerInitialized);
|
|
216
|
+
// Attach crease edges to the freshly-loaded meshes. updateScene clears and re-adds content
|
|
217
|
+
// each solve, so re-run over the scene root every time; addEdges is idempotent per mesh.
|
|
218
|
+
applyEdges?.(scene);
|
|
211
219
|
untrack(() => sceneVersion++);
|
|
212
220
|
|
|
213
221
|
if (!viewerInitialized && meshes.length > 0) {
|
|
@@ -49,7 +49,12 @@ export function createComputeThrottle<T>(
|
|
|
49
49
|
|
|
50
50
|
currentAbortController = new AbortController();
|
|
51
51
|
const { signal } = currentAbortController;
|
|
52
|
-
const timeoutId = setTimeout(() =>
|
|
52
|
+
const timeoutId = setTimeout(() => {
|
|
53
|
+
// Cleared in `finally` on every other path, so firing means a genuine
|
|
54
|
+
// timeout — the only signal for it (the abort itself is swallowed below).
|
|
55
|
+
console.warn(`[Compute/throttle] solve exceeded ${timeout}ms — aborting`);
|
|
56
|
+
currentAbortController?.abort();
|
|
57
|
+
}, timeout);
|
|
53
58
|
|
|
54
59
|
isComputing = true;
|
|
55
60
|
try {
|
|
@@ -76,6 +81,10 @@ export function createComputeThrottle<T>(
|
|
|
76
81
|
|
|
77
82
|
function trigger(values: T) {
|
|
78
83
|
if (isComputing) {
|
|
84
|
+
if (pendingValues !== null) {
|
|
85
|
+
// Latest-wins: the previously-queued values are dropped, not solved.
|
|
86
|
+
console.debug('[Compute/throttle] superseded pending solve (latest-wins)');
|
|
87
|
+
}
|
|
79
88
|
pendingValues = values;
|
|
80
89
|
} else {
|
|
81
90
|
executeCompute(values);
|
|
@@ -203,11 +203,21 @@ export function createRequestResponseDriver(
|
|
|
203
203
|
}
|
|
204
204
|
try {
|
|
205
205
|
const result = await onSolve(values, signal);
|
|
206
|
-
if (signal.aborted)
|
|
206
|
+
if (signal.aborted) {
|
|
207
|
+
// Discarded on purpose (superseded/cancelled) — never memoized or reported.
|
|
208
|
+
console.debug('[Compute/session] solve completed after abort — result discarded');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
207
211
|
memo.set(values, result);
|
|
208
212
|
getReporter().report(result);
|
|
209
213
|
} catch (err) {
|
|
210
|
-
if (signal.aborted)
|
|
214
|
+
if (signal.aborted) {
|
|
215
|
+
console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// reportError only sets reactive state; without this line a transport
|
|
219
|
+
// failure leaves no console trace at all.
|
|
220
|
+
console.warn('[Compute/session] solve failed:', err);
|
|
211
221
|
getReporter().reportError(err instanceof Error ? err.message : String(err));
|
|
212
222
|
}
|
|
213
223
|
}, options);
|
|
@@ -50,6 +50,9 @@ export function createSolveMemo(max = 16): SolveMemo {
|
|
|
50
50
|
// Refresh recency: re-insert at the tail.
|
|
51
51
|
entries.delete(key);
|
|
52
52
|
entries.set(key, hit);
|
|
53
|
+
// A memo hit skips the transport entirely, so no other log line fires —
|
|
54
|
+
// this debug line is the only trace it wasn't a fresh solve.
|
|
55
|
+
console.debug(`[Compute/memo] HIT — served from client memo (${entries.size}/${max})`);
|
|
53
56
|
return hit;
|
|
54
57
|
},
|
|
55
58
|
set(values, result) {
|
|
@@ -60,9 +63,13 @@ export function createSolveMemo(max = 16): SolveMemo {
|
|
|
60
63
|
const oldest = entries.keys().next().value;
|
|
61
64
|
if (oldest === undefined) break;
|
|
62
65
|
entries.delete(oldest);
|
|
66
|
+
console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
|
|
63
67
|
}
|
|
64
68
|
},
|
|
65
69
|
clear() {
|
|
70
|
+
if (entries.size > 0) {
|
|
71
|
+
console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
|
|
72
|
+
}
|
|
66
73
|
entries.clear();
|
|
67
74
|
}
|
|
68
75
|
};
|