@selvajs/ui 4.12.5 → 5.0.0-beta.1

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.
@@ -173,8 +173,14 @@
173
173
  fitToView = init.fitToView;
174
174
  projection = init.cameraController.getProjection();
175
175
 
176
+ const renderer = init.renderer;
177
+
176
178
  return () => {
177
179
  init.dispose();
180
+ // `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
181
+ // on every definition switch; browsers cap live contexts (~16). Explicitly
182
+ // drop this one so the GPU-side context is released now rather than at GC.
183
+ renderer.forceContextLoss();
178
184
  };
179
185
  });
180
186
 
@@ -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,11 +107,25 @@ 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
126
  if (signal.aborted)
110
127
  return;
128
+ memo.set(values, result);
111
129
  getReporter().report(result);
112
130
  }
113
131
  catch (err) {
@@ -125,6 +143,9 @@ export function createRequestResponseDriver(onSolve, getReporter, options = {})
125
143
  },
126
144
  get isSolving() {
127
145
  return throttle.isComputing;
146
+ },
147
+ clearCache() {
148
+ memo.clear();
128
149
  }
129
150
  };
130
151
  }
@@ -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,58 @@
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
+ return hit;
42
+ },
43
+ set(values, result) {
44
+ const key = stableInputKey(values);
45
+ entries.delete(key);
46
+ entries.set(key, result);
47
+ while (entries.size > max) {
48
+ const oldest = entries.keys().next().value;
49
+ if (oldest === undefined)
50
+ break;
51
+ entries.delete(oldest);
52
+ }
53
+ },
54
+ clear() {
55
+ entries.clear();
56
+ }
57
+ };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.12.5",
3
+ "version": "5.0.0-beta.1",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -37,13 +37,13 @@
37
37
  "**/*.css"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@selvajs/compute": "^3.0.1",
40
+ "@selvajs/compute": "^3.1.0-beta.6",
41
41
  "@sveltejs/kit": "^2",
42
42
  "bits-ui": "^2.18.0",
43
43
  "svelte": "^5",
44
44
  "tailwind-variants": "^3.2.2",
45
45
  "three": "^0.184.0",
46
- "@selvajs/schemas": "^4.6.1"
46
+ "@selvajs/schemas": "^4.7.0-beta.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "three": {
@@ -63,17 +63,17 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@internationalized/date": "^3.12.1",
66
- "@sveltejs/kit": "2.58.0",
66
+ "@sveltejs/kit": "2.69.1",
67
67
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
68
68
  "@types/three": "^0.184.0",
69
69
  "bits-ui": "^2.18.0",
70
70
  "rhino3dm": "8.17.0",
71
71
  "rimraf": "^6.0.1",
72
- "svelte": "5.55.5",
72
+ "svelte": "5.56.4",
73
73
  "tailwind-variants": "^3.2.2",
74
74
  "vitest": "^3.2.6",
75
75
  "@selvajs/config": "0.0.2",
76
- "@selvajs/schemas": "4.6.1"
76
+ "@selvajs/schemas": "4.7.0-beta.0"
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "vite dev",
@@ -173,8 +173,14 @@
173
173
  fitToView = init.fitToView;
174
174
  projection = init.cameraController.getProjection();
175
175
 
176
+ const renderer = init.renderer;
177
+
176
178
  return () => {
177
179
  init.dispose();
180
+ // `{#key definitionKey}` recreates the canvas + WebGLRenderer + GL context
181
+ // on every definition switch; browsers cap live contexts (~16). Explicitly
182
+ // drop this one so the GPU-side context is released now rather than at GC.
183
+ renderer.forceContextLoss();
178
184
  };
179
185
  });
180
186
 
@@ -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,10 +187,24 @@ 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
206
  if (signal.aborted) return;
207
+ memo.set(values, result);
184
208
  getReporter().report(result);
185
209
  } catch (err) {
186
210
  if (signal.aborted) return;
@@ -197,6 +221,9 @@ export function createRequestResponseDriver(
197
221
  },
198
222
  get isSolving() {
199
223
  return throttle.isComputing;
224
+ },
225
+ clearCache() {
226
+ memo.clear();
200
227
  }
201
228
  };
202
229
  }
@@ -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,69 @@
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
+ return hit;
54
+ },
55
+ set(values, result) {
56
+ const key = stableInputKey(values);
57
+ entries.delete(key);
58
+ entries.set(key, result);
59
+ while (entries.size > max) {
60
+ const oldest = entries.keys().next().value;
61
+ if (oldest === undefined) break;
62
+ entries.delete(oldest);
63
+ }
64
+ },
65
+ clear() {
66
+ entries.clear();
67
+ }
68
+ };
69
+ }