@selvajs/ui 5.0.0-beta.0 → 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.
@@ -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
- const getSceneObjects = () => {
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
- const getLayerGroups = () => {
32
- const objects = getSceneObjects();
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 allObjects = getSceneObjects();
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
- const getFilteredLayerGroups = () => {
108
- const groups = getLayerGroups();
109
- if (!searchQuery.trim()) return groups;
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 groups) {
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 getFilteredLayerGroups()) {
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 [...getFilteredLayerGroups()] as [layerName, objects] (layerName)}
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 getSceneObjects().length === 0}
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 getFilteredLayerGroups().size === 0}
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,11 +174,18 @@
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
 
181
+ const renderer = init.renderer;
182
+
176
183
  return () => {
177
184
  init.dispose();
185
+ // `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
186
+ // on every definition switch; browsers cap live contexts (~16). Explicitly
187
+ // drop this one so the GPU-side context is released now rather than at GC.
188
+ renderer.forceContextLoss();
178
189
  };
179
190
  });
180
191
 
@@ -202,6 +213,9 @@
202
213
  $effect(() => {
203
214
  if (scene && camera && controls) {
204
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);
205
219
  untrack(() => sceneVersion++);
206
220
 
207
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(() => currentAbortController?.abort(), timeout);
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 {
@@ -9,6 +9,12 @@ export interface SolveDriver {
9
9
  solve(values: Record<string, unknown>): void;
10
10
  cancel(): void;
11
11
  readonly isSolving: boolean;
12
+ /**
13
+ * Drops any cached solve results the driver holds. Optional — only drivers with a
14
+ * client-side memo (the request/response driver) implement it. Called on rebuild so a
15
+ * definition swap can't serve a stale result from a prior definition's input space.
16
+ */
17
+ clearCache?(): void;
12
18
  }
13
19
  export interface SolveSession {
14
20
  readonly values: Record<string, unknown>;
@@ -4,6 +4,7 @@
4
4
  // SolveDriver. A completed solve re-enters via report().
5
5
  import { readExternalValue } from '../external/storage';
6
6
  import { createComputeThrottle } from './computeThrottle.svelte';
7
+ import { createSolveMemo } from './solveMemo';
7
8
  import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
8
9
  export function createSolveSession(args) {
9
10
  let currentSchema = args.schema;
@@ -73,6 +74,9 @@ export function createSolveSession(args) {
73
74
  },
74
75
  rebuild(schema, scopeKey) {
75
76
  currentSchema = schema;
77
+ // Drop the driver's result memo: the new definition has its own input space, so
78
+ // a matching input key from the prior definition must not serve its stale result.
79
+ args.driver.clearCache?.();
76
80
  state.meshes = [];
77
81
  state.error = '';
78
82
  state.computeErrors = [];
@@ -103,16 +107,38 @@ export function createSolveSession(args) {
103
107
  * lazily (`() => session`) so it can construct the session with the driver in hand.
104
108
  */
105
109
  export function createRequestResponseDriver(onSolve, getReporter, options = {}) {
110
+ // M2: a small LRU memoizing completed solves by their input values. A slider dragged
111
+ // back to a value already solved this session reports instantly without a network
112
+ // round-trip. The check lives inside the throttled computeFn so the throttle's
113
+ // latest-wins ordering still holds — a hit only serves after the throttle picks these
114
+ // values as the ones to run.
115
+ const memo = createSolveMemo();
106
116
  const throttle = createComputeThrottle(async (values, signal) => {
117
+ const cached = memo.get(values);
118
+ if (cached !== undefined) {
119
+ if (signal.aborted)
120
+ return;
121
+ getReporter().report(cached);
122
+ return;
123
+ }
107
124
  try {
108
125
  const result = await onSolve(values, signal);
109
- 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');
110
129
  return;
130
+ }
131
+ memo.set(values, result);
111
132
  getReporter().report(result);
112
133
  }
113
134
  catch (err) {
114
- if (signal.aborted)
135
+ if (signal.aborted) {
136
+ console.debug('[Compute/session] solve aborted (superseded, cancelled, or timed out)');
115
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);
116
142
  getReporter().reportError(err instanceof Error ? err.message : String(err));
117
143
  }
118
144
  }, options);
@@ -125,6 +151,9 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
125
151
  },
126
152
  get isSolving() {
127
153
  return throttle.isComputing;
154
+ },
155
+ clearCache() {
156
+ memo.clear();
128
157
  }
129
158
  };
130
159
  }
@@ -0,0 +1,22 @@
1
+ import type { SolveResult } from '../types/solveFn';
2
+ /**
3
+ * Deterministic string key for a set of input values. Object keys are sorted at every
4
+ * level so two logically-equal inputs (built in different key order) collide, matching
5
+ * the server's stable-input keying intent. Values are plain JSON (numbers, strings,
6
+ * booleans, arrays) — the projected solve inputs never contain functions or cycles.
7
+ */
8
+ export declare function stableInputKey(values: Record<string, unknown>): string;
9
+ export interface SolveMemo {
10
+ /** Returns a previously stored result for these inputs, or undefined on a miss. */
11
+ get(values: Record<string, unknown>): SolveResult | undefined;
12
+ /** Records a completed solve result under its input key (evicting the LRU tail). */
13
+ set(values: Record<string, unknown>, result: SolveResult): void;
14
+ /** Drops every entry — called when the active definition changes. */
15
+ clear(): void;
16
+ }
17
+ /**
18
+ * A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
19
+ * default is deliberately small — this targets the tight slider-scrub loop, not a durable
20
+ * cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
21
+ */
22
+ export declare function createSolveMemo(max?: number): SolveMemo;
@@ -0,0 +1,65 @@
1
+ // Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
2
+ // the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
3
+ // back to a value already solved this session returns instantly without a network
4
+ // round-trip — killing slider-scrub storms before they leave the browser. It pairs with
5
+ // the throttle's latest-wins abort: the memo only serves values that fully solved, so a
6
+ // hit is always a complete result.
7
+ /**
8
+ * Deterministic string key for a set of input values. Object keys are sorted at every
9
+ * level so two logically-equal inputs (built in different key order) collide, matching
10
+ * the server's stable-input keying intent. Values are plain JSON (numbers, strings,
11
+ * booleans, arrays) — the projected solve inputs never contain functions or cycles.
12
+ */
13
+ export function stableInputKey(values) {
14
+ return serialize(values);
15
+ }
16
+ function serialize(value) {
17
+ if (value === null || typeof value !== 'object')
18
+ return JSON.stringify(value) ?? 'null';
19
+ if (Array.isArray(value))
20
+ return `[${value.map(serialize).join(',')}]`;
21
+ const obj = value;
22
+ const keys = Object.keys(obj).sort();
23
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
24
+ }
25
+ /**
26
+ * A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
27
+ * default is deliberately small — this targets the tight slider-scrub loop, not a durable
28
+ * cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
29
+ */
30
+ export function createSolveMemo(max = 16) {
31
+ const entries = new Map();
32
+ return {
33
+ get(values) {
34
+ const key = stableInputKey(values);
35
+ const hit = entries.get(key);
36
+ if (hit === undefined)
37
+ return undefined;
38
+ // Refresh recency: re-insert at the tail.
39
+ entries.delete(key);
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})`);
44
+ return hit;
45
+ },
46
+ set(values, result) {
47
+ const key = stableInputKey(values);
48
+ entries.delete(key);
49
+ entries.set(key, result);
50
+ while (entries.size > max) {
51
+ const oldest = entries.keys().next().value;
52
+ if (oldest === undefined)
53
+ break;
54
+ entries.delete(oldest);
55
+ console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
56
+ }
57
+ },
58
+ clear() {
59
+ if (entries.size > 0) {
60
+ console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
61
+ }
62
+ entries.clear();
63
+ }
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "5.0.0-beta.0",
3
+ "version": "5.0.0-beta.2",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -37,7 +37,7 @@
37
37
  "**/*.css"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@selvajs/compute": "^3.1.0-beta.3",
40
+ "@selvajs/compute": "^3.1.0-beta.6",
41
41
  "@sveltejs/kit": "^2",
42
42
  "bits-ui": "^2.18.0",
43
43
  "svelte": "^5",
@@ -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
- const getSceneObjects = () => {
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
- const getLayerGroups = () => {
32
- const objects = getSceneObjects();
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 allObjects = getSceneObjects();
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
- const getFilteredLayerGroups = () => {
108
- const groups = getLayerGroups();
109
- if (!searchQuery.trim()) return groups;
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 groups) {
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 getFilteredLayerGroups()) {
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 [...getFilteredLayerGroups()] as [layerName, objects] (layerName)}
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 getSceneObjects().length === 0}
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 getFilteredLayerGroups().size === 0}
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,11 +174,18 @@
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
 
181
+ const renderer = init.renderer;
182
+
176
183
  return () => {
177
184
  init.dispose();
185
+ // `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
186
+ // on every definition switch; browsers cap live contexts (~16). Explicitly
187
+ // drop this one so the GPU-side context is released now rather than at GC.
188
+ renderer.forceContextLoss();
178
189
  };
179
190
  });
180
191
 
@@ -202,6 +213,9 @@
202
213
  $effect(() => {
203
214
  if (scene && camera && controls) {
204
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);
205
219
  untrack(() => sceneVersion++);
206
220
 
207
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(() => currentAbortController?.abort(), timeout);
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);
@@ -7,6 +7,7 @@ import type { UISchema } from '@selvajs/schemas';
7
7
  import { readExternalValue } from '../external/storage';
8
8
  import type { SolveFn, SolveResult } from '../types/solveFn';
9
9
  import { createComputeThrottle } from './computeThrottle.svelte';
10
+ import { createSolveMemo } from './solveMemo';
10
11
  import {
11
12
  buildInitialValues,
12
13
  makeInitialFlags,
@@ -25,6 +26,12 @@ export interface SolveDriver {
25
26
  solve(values: Record<string, unknown>): void;
26
27
  cancel(): void;
27
28
  readonly isSolving: boolean;
29
+ /**
30
+ * Drops any cached solve results the driver holds. Optional — only drivers with a
31
+ * client-side memo (the request/response driver) implement it. Called on rebuild so a
32
+ * definition swap can't serve a stale result from a prior definition's input space.
33
+ */
34
+ clearCache?(): void;
28
35
  }
29
36
 
30
37
  export interface SolveSession {
@@ -134,6 +141,9 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
134
141
 
135
142
  rebuild(schema, scopeKey) {
136
143
  currentSchema = schema;
144
+ // Drop the driver's result memo: the new definition has its own input space, so
145
+ // a matching input key from the prior definition must not serve its stale result.
146
+ args.driver.clearCache?.();
137
147
  state.meshes = [];
138
148
  state.error = '';
139
149
  state.computeErrors = [];
@@ -177,13 +187,37 @@ export function createRequestResponseDriver(
177
187
  getReporter: () => SolveReporter,
178
188
  options: { timeout?: number } = {}
179
189
  ): SolveDriver {
190
+ // M2: a small LRU memoizing completed solves by their input values. A slider dragged
191
+ // back to a value already solved this session reports instantly without a network
192
+ // round-trip. The check lives inside the throttled computeFn so the throttle's
193
+ // latest-wins ordering still holds — a hit only serves after the throttle picks these
194
+ // values as the ones to run.
195
+ const memo = createSolveMemo();
196
+
180
197
  const throttle = createComputeThrottle<Record<string, unknown>>(async (values, signal) => {
198
+ const cached = memo.get(values);
199
+ if (cached !== undefined) {
200
+ if (signal.aborted) return;
201
+ getReporter().report(cached);
202
+ return;
203
+ }
181
204
  try {
182
205
  const result = await onSolve(values, signal);
183
- if (signal.aborted) return;
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
+ }
211
+ memo.set(values, result);
184
212
  getReporter().report(result);
185
213
  } catch (err) {
186
- if (signal.aborted) return;
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);
187
221
  getReporter().reportError(err instanceof Error ? err.message : String(err));
188
222
  }
189
223
  }, options);
@@ -197,6 +231,9 @@ export function createRequestResponseDriver(
197
231
  },
198
232
  get isSolving() {
199
233
  return throttle.isComputing;
234
+ },
235
+ clearCache() {
236
+ memo.clear();
200
237
  }
201
238
  };
202
239
  }
@@ -1,6 +1,12 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { createSolveSession, type SolveDriver } from './createSolveSession.svelte';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createSolveSession,
4
+ createRequestResponseDriver,
5
+ type SolveDriver,
6
+ type SolveReporter
7
+ } from './createSolveSession.svelte';
3
8
  import type { UISchema } from '@selvajs/schemas';
9
+ import type { SolveResult } from '../types/solveFn';
4
10
 
5
11
  // Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
6
12
  // added for dynamic-value-list reconciliation. The pure transition logic is pinned in
@@ -83,3 +89,80 @@ describe('createSolveSession.setValue', () => {
83
89
  expect(driver.solves[0]).not.toHaveProperty('out');
84
90
  });
85
91
  });
92
+
93
+ // M2: the request/response driver's client-side result memo. Verifies a slider returning
94
+ // to a solved value serves from memory (no onSolve call) and that a definition rebuild
95
+ // drops the memo so a stale result can't cross the swap.
96
+ describe('createRequestResponseDriver — client memo', () => {
97
+ // Collects reported results so the memo hit/miss can be observed without a session.
98
+ function collectingReporter(): SolveReporter & { reports: SolveResult[]; errors: string[] } {
99
+ const reports: SolveResult[] = [];
100
+ const errors: string[] = [];
101
+ return {
102
+ reports,
103
+ errors,
104
+ report: (r) => reports.push(r),
105
+ reportError: (m) => errors.push(m)
106
+ };
107
+ }
108
+
109
+ // Lets the throttle's fire-and-forget executeCompute settle.
110
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
111
+
112
+ it('serves a repeated input from the memo without calling onSolve again', async () => {
113
+ const onSolve = vi.fn(
114
+ async (values: Record<string, unknown>): Promise<SolveResult> => ({
115
+ outputs: { echo: values.a }
116
+ })
117
+ );
118
+ const reporter = collectingReporter();
119
+ const driver = createRequestResponseDriver(onSolve, () => reporter);
120
+
121
+ driver.solve({ a: 1 });
122
+ await flush();
123
+ driver.solve({ a: 2 });
124
+ await flush();
125
+ driver.solve({ a: 1 }); // repeat — should hit the memo
126
+ await flush();
127
+
128
+ expect(onSolve).toHaveBeenCalledTimes(2); // only the two distinct inputs
129
+ expect(reporter.reports).toHaveLength(3); // but all three solves reported
130
+ expect(reporter.reports[2]).toEqual({ outputs: { echo: 1 } });
131
+ });
132
+
133
+ it('clearCache drops the memo so the next identical solve re-runs', async () => {
134
+ const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
135
+ const reporter = collectingReporter();
136
+ const driver = createRequestResponseDriver(onSolve, () => reporter);
137
+
138
+ driver.solve({ a: 1 });
139
+ await flush();
140
+ driver.clearCache?.();
141
+ driver.solve({ a: 1 }); // memo cleared → real solve again
142
+ await flush();
143
+
144
+ expect(onSolve).toHaveBeenCalledTimes(2);
145
+ });
146
+
147
+ it('session.rebuild clears the driver memo (no cross-definition stale hit)', async () => {
148
+ const onSolve = vi.fn(async (): Promise<SolveResult> => ({ outputs: {} }));
149
+ const reporter = collectingReporter();
150
+ let clears = 0;
151
+ // Wrap the real driver to observe clearCache being invoked from rebuild.
152
+ const base = createRequestResponseDriver(onSolve, () => reporter);
153
+ const driver: SolveDriver = {
154
+ solve: base.solve,
155
+ cancel: base.cancel,
156
+ get isSolving() {
157
+ return base.isSolving;
158
+ },
159
+ clearCache() {
160
+ clears += 1;
161
+ base.clearCache?.();
162
+ }
163
+ };
164
+ const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
165
+ session.rebuild(schema(true), 's2');
166
+ expect(clears).toBe(1);
167
+ });
168
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createSolveMemo, stableInputKey } from './solveMemo';
3
+ import type { SolveResult } from '../types/solveFn';
4
+
5
+ // Pins the client-side result memo (M2): stable keying across key order, LRU recency and
6
+ // eviction, hit/miss semantics, and clear(). The driver wiring is pinned separately in
7
+ // createSolveSession.test.ts.
8
+
9
+ const result = (tag: string): SolveResult => ({ outputs: { out: tag } });
10
+
11
+ describe('stableInputKey', () => {
12
+ it('is insensitive to object key order', () => {
13
+ expect(stableInputKey({ a: 1, b: 2 })).toBe(stableInputKey({ b: 2, a: 1 }));
14
+ });
15
+
16
+ it('sorts keys at every level (nested objects)', () => {
17
+ expect(stableInputKey({ o: { x: 1, y: 2 } })).toBe(stableInputKey({ o: { y: 2, x: 1 } }));
18
+ });
19
+
20
+ it('distinguishes different values', () => {
21
+ expect(stableInputKey({ a: 1 })).not.toBe(stableInputKey({ a: 2 }));
22
+ });
23
+
24
+ it('preserves array order (arrays are ordered)', () => {
25
+ expect(stableInputKey({ a: [1, 2] })).not.toBe(stableInputKey({ a: [2, 1] }));
26
+ });
27
+
28
+ it('handles null and primitive values', () => {
29
+ expect(stableInputKey({ a: null, b: 'x', c: true })).toBe(
30
+ stableInputKey({ c: true, b: 'x', a: null })
31
+ );
32
+ });
33
+ });
34
+
35
+ describe('createSolveMemo', () => {
36
+ it('returns undefined on a miss', () => {
37
+ const memo = createSolveMemo();
38
+ expect(memo.get({ a: 1 })).toBeUndefined();
39
+ });
40
+
41
+ it('round-trips a stored result by equal inputs regardless of key order', () => {
42
+ const memo = createSolveMemo();
43
+ memo.set({ a: 1, b: 2 }, result('r'));
44
+ expect(memo.get({ b: 2, a: 1 })).toEqual(result('r'));
45
+ });
46
+
47
+ it('caches errored results (a complete, deterministic solve outcome)', () => {
48
+ const memo = createSolveMemo();
49
+ const errored: SolveResult = { outputs: {}, errors: ['boom'] };
50
+ memo.set({ a: 1 }, errored);
51
+ expect(memo.get({ a: 1 })).toEqual(errored);
52
+ });
53
+
54
+ it('evicts the least-recently-used entry past capacity', () => {
55
+ const memo = createSolveMemo(2);
56
+ memo.set({ k: 1 }, result('1'));
57
+ memo.set({ k: 2 }, result('2'));
58
+ memo.set({ k: 3 }, result('3')); // evicts k:1
59
+ expect(memo.get({ k: 1 })).toBeUndefined();
60
+ expect(memo.get({ k: 2 })).toEqual(result('2'));
61
+ expect(memo.get({ k: 3 })).toEqual(result('3'));
62
+ });
63
+
64
+ it('a get refreshes recency, protecting the entry from eviction', () => {
65
+ const memo = createSolveMemo(2);
66
+ memo.set({ k: 1 }, result('1'));
67
+ memo.set({ k: 2 }, result('2'));
68
+ memo.get({ k: 1 }); // k:1 now most-recent
69
+ memo.set({ k: 3 }, result('3')); // evicts k:2, not k:1
70
+ expect(memo.get({ k: 1 })).toEqual(result('1'));
71
+ expect(memo.get({ k: 2 })).toBeUndefined();
72
+ });
73
+
74
+ it('re-setting an existing key updates the value without growing size', () => {
75
+ const memo = createSolveMemo(1);
76
+ memo.set({ k: 1 }, result('old'));
77
+ memo.set({ k: 1 }, result('new'));
78
+ expect(memo.get({ k: 1 })).toEqual(result('new'));
79
+ });
80
+
81
+ it('clear() drops every entry', () => {
82
+ const memo = createSolveMemo();
83
+ memo.set({ a: 1 }, result('r'));
84
+ memo.clear();
85
+ expect(memo.get({ a: 1 })).toBeUndefined();
86
+ });
87
+ });
@@ -0,0 +1,76 @@
1
+ // Client-side solve result memo (M2). A small LRU keyed on a stable serialization of
2
+ // the solve INPUTS, sitting in front of the request/response driver. Dragging a slider
3
+ // back to a value already solved this session returns instantly without a network
4
+ // round-trip — killing slider-scrub storms before they leave the browser. It pairs with
5
+ // the throttle's latest-wins abort: the memo only serves values that fully solved, so a
6
+ // hit is always a complete result.
7
+
8
+ import type { SolveResult } from '../types/solveFn';
9
+
10
+ /**
11
+ * Deterministic string key for a set of input values. Object keys are sorted at every
12
+ * level so two logically-equal inputs (built in different key order) collide, matching
13
+ * the server's stable-input keying intent. Values are plain JSON (numbers, strings,
14
+ * booleans, arrays) — the projected solve inputs never contain functions or cycles.
15
+ */
16
+ export function stableInputKey(values: Record<string, unknown>): string {
17
+ return serialize(values);
18
+ }
19
+
20
+ function serialize(value: unknown): string {
21
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
22
+ if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
23
+ const obj = value as Record<string, unknown>;
24
+ const keys = Object.keys(obj).sort();
25
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${serialize(obj[k])}`).join(',')}}`;
26
+ }
27
+
28
+ export interface SolveMemo {
29
+ /** Returns a previously stored result for these inputs, or undefined on a miss. */
30
+ get(values: Record<string, unknown>): SolveResult | undefined;
31
+ /** Records a completed solve result under its input key (evicting the LRU tail). */
32
+ set(values: Record<string, unknown>, result: SolveResult): void;
33
+ /** Drops every entry — called when the active definition changes. */
34
+ clear(): void;
35
+ }
36
+
37
+ /**
38
+ * A bounded LRU memo. `max` caps entries (not bytes); solve results can be large, so the
39
+ * default is deliberately small — this targets the tight slider-scrub loop, not a durable
40
+ * cache. Re-reading an entry refreshes its recency (Map insertion-order LRU).
41
+ */
42
+ export function createSolveMemo(max = 16): SolveMemo {
43
+ const entries = new Map<string, SolveResult>();
44
+
45
+ return {
46
+ get(values) {
47
+ const key = stableInputKey(values);
48
+ const hit = entries.get(key);
49
+ if (hit === undefined) return undefined;
50
+ // Refresh recency: re-insert at the tail.
51
+ entries.delete(key);
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})`);
56
+ return hit;
57
+ },
58
+ set(values, result) {
59
+ const key = stableInputKey(values);
60
+ entries.delete(key);
61
+ entries.set(key, result);
62
+ while (entries.size > max) {
63
+ const oldest = entries.keys().next().value;
64
+ if (oldest === undefined) break;
65
+ entries.delete(oldest);
66
+ console.debug(`[Compute/memo] evicted LRU entry (cap ${max})`);
67
+ }
68
+ },
69
+ clear() {
70
+ if (entries.size > 0) {
71
+ console.debug(`[Compute/memo] cleared ${entries.size} entries (definition changed)`);
72
+ }
73
+ entries.clear();
74
+ }
75
+ };
76
+ }