@selvajs/ui 4.12.1 → 4.12.3

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.
@@ -115,10 +115,39 @@
115
115
  // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
116
116
  // schemas would keep the prior output on screen, making it look like the auto-picked option
117
117
  // produced it.
118
+ // Distinguishes "never selected" from "user deliberately cleared": the empty→
119
+ // first-option fallback below must not fight a real user action (e.g. unchecking
120
+ // every checklist entry), only fill the initial void.
121
+ let userTouched = false;
122
+
123
+ // Loop breaker for the system fallback. A definition whose computed options
124
+ // DEPEND on the selection can oscillate: auto-pick A → solve → new options
125
+ // exclude A → auto-pick B → solve → … Each cycle force-solves and re-renders
126
+ // the option list, which can run a tab out of memory. Bound consecutive
127
+ // system-initiated picks; any real user commit resets the budget.
128
+ const MAX_CONSECUTIVE_AUTO_PICKS = 3;
129
+ let autoPickCount = 0;
130
+
118
131
  $effect(() => {
119
132
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
120
133
  const validValues = new Set(Object.values(dynamicListOptions));
121
134
  const firstOption = Object.values(dynamicListOptions)[0];
135
+ // Selection is valid → stable state reached; refill the auto-pick budget.
136
+ const isValid = Array.isArray(value)
137
+ ? value.length > 0 && value.every((v) => typeof v === 'string' && validValues.has(v))
138
+ : typeof value === 'string' && value !== '' && validValues.has(value);
139
+ if (isValid) {
140
+ autoPickCount = 0;
141
+ return;
142
+ }
143
+ if (autoPickCount >= MAX_CONSECUTIVE_AUTO_PICKS) {
144
+ console.warn(
145
+ `[InputControl] dynamic value list "${item.paramId}": options keep invalidating the ` +
146
+ `auto-picked selection (${autoPickCount} consecutive system picks) — the definition's ` +
147
+ `options likely depend on the selection. Stopping auto-reconciliation to avoid a solve loop.`
148
+ );
149
+ return;
150
+ }
122
151
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
123
152
  // directly from an effect trips Svelte's binding-ownership check.
124
153
  // A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
@@ -126,24 +155,37 @@
126
155
  // as an empty string, which cascades through the definition as null-data errors
127
156
  // ("File not found: .dmf", Text→Number conversion failures, …) and the empty
128
157
  // result then gets replayed by the solve caches.
158
+ // Diagnostic: each system pick logs itself — a runaway sequence of these lines
159
+ // (pick #1, #2, #3 + the loop-breaker warning) is the solve-loop signature.
160
+ const logAutoPick = (picked: string) =>
161
+ console.info(
162
+ `[DVL] auto-pick #${autoPickCount} on "${item.paramId}": "${picked.slice(0, 60)}" ` +
163
+ `(was ${JSON.stringify(Array.isArray(value) ? value.slice(0, 3) : value)?.slice(0, 80)})`
164
+ );
129
165
  if (Array.isArray(value)) {
130
166
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
131
- if (value.length === 0 || pruned.length !== value.length) {
132
- // Fall back to first option when the checklist is empty (never selected
133
- // or fully pruned).
167
+ if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
168
+ // Fall back to first option when the checklist is fully pruned or was
169
+ // never selected; a user-cleared checklist stays empty.
170
+ autoPickCount++;
171
+ logAutoPick(pruned.length > 0 ? String(pruned[0]) : firstOption);
134
172
  onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
135
173
  }
136
174
  } else if (
137
- value == null ||
138
- value === '' ||
139
- (typeof value === 'string' && !validValues.has(value))
175
+ (typeof value === 'string' && value && !validValues.has(value)) ||
176
+ ((value == null || value === '') && !userTouched)
140
177
  ) {
141
- // Stale or never-selected single value — fall back to the first option.
178
+ // Stale single value, or never-selected — fall back to the first option.
179
+ autoPickCount++;
180
+ logAutoPick(firstOption);
142
181
  onChange(item.paramId, firstOption, true);
143
182
  }
144
183
  });
145
184
 
146
185
  function commit(newValue: SupportedTypes) {
186
+ userTouched = true;
187
+ // A real user pick re-arms the system fallback (see autoPickCount above).
188
+ autoPickCount = 0;
147
189
  value = newValue;
148
190
  onChange(item.paramId, newValue);
149
191
  }
@@ -4,7 +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 { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult } from './solve-session-core';
7
+ import { buildInitialValues, makeInitialFlags, applyValueChange, applySolveResult, pickInputValues } from './solve-session-core';
8
8
  export function createSolveSession(args) {
9
9
  let currentSchema = args.schema;
10
10
  const flags = makeInitialFlags(currentSchema?.instanceSolve);
@@ -19,7 +19,9 @@ export function createSolveSession(args) {
19
19
  hasNeverSolved: flags.hasNeverSolved
20
20
  });
21
21
  function dispatch() {
22
- args.driver.solve($state.snapshot(state.values));
22
+ // Input values only: outputs merged into state.values (for widgets that read
23
+ // them, e.g. dynamic value lists) must not be echoed back to the transport.
24
+ args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
23
25
  }
24
26
  return {
25
27
  get values() {
@@ -38,6 +38,15 @@ export declare function applyValueChange(state: SolveSessionState, id: string, v
38
38
  state: SolveSessionState;
39
39
  shouldSolve: boolean;
40
40
  };
41
+ /**
42
+ * Projects the session's live values down to solve INPUTS. Solve outputs are merged
43
+ * into the same values map after each solve (applySolveResult) so widgets like
44
+ * dynamic value lists can read them — but they are not solve inputs, and echoing
45
+ * them back to the driver re-uploads potentially MB-sized payloads (a measured
46
+ * 6.4 MB options list) that no backend reads. Every transport gets this projection
47
+ * for free by going through the session's dispatch.
48
+ */
49
+ export declare function pickInputValues(schema: UISchema | undefined, values: Record<string, unknown>): Record<string, unknown>;
41
50
  /**
42
51
  * Merges a reported solve result into the state and clears the post-solve lifecycle
43
52
  * flags. Missing result arrays are treated as empty.
@@ -52,6 +52,24 @@ export function applyValueChange(state, id, value, instanceSolve) {
52
52
  }
53
53
  return { state, shouldSolve: true };
54
54
  }
55
+ /**
56
+ * Projects the session's live values down to solve INPUTS. Solve outputs are merged
57
+ * into the same values map after each solve (applySolveResult) so widgets like
58
+ * dynamic value lists can read them — but they are not solve inputs, and echoing
59
+ * them back to the driver re-uploads potentially MB-sized payloads (a measured
60
+ * 6.4 MB options list) that no backend reads. Every transport gets this projection
61
+ * for free by going through the session's dispatch.
62
+ */
63
+ export function pickInputValues(schema, values) {
64
+ if (!schema?.inputs)
65
+ return values;
66
+ const picked = {};
67
+ for (const input of schema.inputs) {
68
+ if (input.id in values)
69
+ picked[input.id] = values[input.id];
70
+ }
71
+ return picked;
72
+ }
55
73
  /**
56
74
  * Merges a reported solve result into the state and clears the post-solve lifecycle
57
75
  * flags. Missing result arrays are treated as empty.
@@ -26,6 +26,45 @@ function coercePayload(value) {
26
26
  }
27
27
  return null;
28
28
  }
29
+ // Memoizes string-payload coercion. In compute mode the payload arrives as a JSON
30
+ // string — potentially several MB for a large options list — and the options map is
31
+ // derived from `values` (TabLayout), so it recomputes on EVERY value change. Without
32
+ // memoization each keystroke/output-merge re-parses megabytes and allocates a fresh
33
+ // options object, whose new identity re-renders the entire dropdown subtree; with a
34
+ // 6.4 MB payload this was measured driving a tab out of memory. Map keys use
35
+ // SameValueZero, so an identical response string from a later solve also hits.
36
+ const coerceCache = new Map();
37
+ const COERCE_CACHE_MAX = 8;
38
+ function coercePayloadMemo(value) {
39
+ // Only string payloads are expensive (JSON.parse); objects pass straight through.
40
+ if (typeof value !== 'string' || value.length < 1024)
41
+ return coercePayload(value);
42
+ const hit = coerceCache.get(value);
43
+ if (hit !== undefined || coerceCache.has(value)) {
44
+ // Refresh LRU position.
45
+ coerceCache.delete(value);
46
+ coerceCache.set(value, hit ?? null);
47
+ return hit ?? null;
48
+ }
49
+ const parseStart = performance.now();
50
+ const parsed = coercePayload(value);
51
+ // Diagnostic: an expensive parse should happen ONCE per distinct solve result.
52
+ // If this line storms in the console, payload memoization is being defeated
53
+ // (e.g. payload strings differing per recompute) — the pre-memoization churn
54
+ // pattern that could OOM a tab.
55
+ if (value.length > 256 * 1024) {
56
+ const optionCount = parsed?.options ? Object.keys(parsed.options).length : 0;
57
+ console.info(`[DVL] parsed ${(value.length / (1024 * 1024)).toFixed(1)} MB options payload ` +
58
+ `(${optionCount} options) in ${(performance.now() - parseStart).toFixed(0)}ms — cache miss`);
59
+ }
60
+ if (coerceCache.size >= COERCE_CACHE_MAX) {
61
+ const oldest = coerceCache.keys().next().value;
62
+ if (oldest !== undefined)
63
+ coerceCache.delete(oldest);
64
+ }
65
+ coerceCache.set(value, parsed);
66
+ return parsed;
67
+ }
29
68
  /**
30
69
  * Every dynamicValueList output reference in the schema.
31
70
  *
@@ -62,7 +101,7 @@ function collectDynamicValueListSources(schema) {
62
101
  export function buildDynamicValueListOptions(schema, values) {
63
102
  const result = {};
64
103
  for (const source of collectDynamicValueListSources(schema)) {
65
- const payload = coercePayload(values[source.id]);
104
+ const payload = coercePayloadMemo(values[source.id]);
66
105
  if (!payload)
67
106
  continue;
68
107
  const targetInputId = payload.targetInputId ?? source.targetInputId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.12.1",
3
+ "version": "4.12.3",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -72,8 +72,8 @@
72
72
  "svelte": "5.55.5",
73
73
  "tailwind-variants": "^3.2.2",
74
74
  "vitest": "^3.2.6",
75
- "@selvajs/schemas": "4.6.1",
76
- "@selvajs/config": "0.0.2"
75
+ "@selvajs/config": "0.0.2",
76
+ "@selvajs/schemas": "4.6.1"
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "vite dev",
@@ -115,10 +115,39 @@
115
115
  // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
116
116
  // schemas would keep the prior output on screen, making it look like the auto-picked option
117
117
  // produced it.
118
+ // Distinguishes "never selected" from "user deliberately cleared": the empty→
119
+ // first-option fallback below must not fight a real user action (e.g. unchecking
120
+ // every checklist entry), only fill the initial void.
121
+ let userTouched = false;
122
+
123
+ // Loop breaker for the system fallback. A definition whose computed options
124
+ // DEPEND on the selection can oscillate: auto-pick A → solve → new options
125
+ // exclude A → auto-pick B → solve → … Each cycle force-solves and re-renders
126
+ // the option list, which can run a tab out of memory. Bound consecutive
127
+ // system-initiated picks; any real user commit resets the budget.
128
+ const MAX_CONSECUTIVE_AUTO_PICKS = 3;
129
+ let autoPickCount = 0;
130
+
118
131
  $effect(() => {
119
132
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
120
133
  const validValues = new Set(Object.values(dynamicListOptions));
121
134
  const firstOption = Object.values(dynamicListOptions)[0];
135
+ // Selection is valid → stable state reached; refill the auto-pick budget.
136
+ const isValid = Array.isArray(value)
137
+ ? value.length > 0 && value.every((v) => typeof v === 'string' && validValues.has(v))
138
+ : typeof value === 'string' && value !== '' && validValues.has(value);
139
+ if (isValid) {
140
+ autoPickCount = 0;
141
+ return;
142
+ }
143
+ if (autoPickCount >= MAX_CONSECUTIVE_AUTO_PICKS) {
144
+ console.warn(
145
+ `[InputControl] dynamic value list "${item.paramId}": options keep invalidating the ` +
146
+ `auto-picked selection (${autoPickCount} consecutive system picks) — the definition's ` +
147
+ `options likely depend on the selection. Stopping auto-reconciliation to avoid a solve loop.`
148
+ );
149
+ return;
150
+ }
122
151
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
123
152
  // directly from an effect trips Svelte's binding-ownership check.
124
153
  // A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
@@ -126,24 +155,37 @@
126
155
  // as an empty string, which cascades through the definition as null-data errors
127
156
  // ("File not found: .dmf", Text→Number conversion failures, …) and the empty
128
157
  // result then gets replayed by the solve caches.
158
+ // Diagnostic: each system pick logs itself — a runaway sequence of these lines
159
+ // (pick #1, #2, #3 + the loop-breaker warning) is the solve-loop signature.
160
+ const logAutoPick = (picked: string) =>
161
+ console.info(
162
+ `[DVL] auto-pick #${autoPickCount} on "${item.paramId}": "${picked.slice(0, 60)}" ` +
163
+ `(was ${JSON.stringify(Array.isArray(value) ? value.slice(0, 3) : value)?.slice(0, 80)})`
164
+ );
129
165
  if (Array.isArray(value)) {
130
166
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
131
- if (value.length === 0 || pruned.length !== value.length) {
132
- // Fall back to first option when the checklist is empty (never selected
133
- // or fully pruned).
167
+ if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
168
+ // Fall back to first option when the checklist is fully pruned or was
169
+ // never selected; a user-cleared checklist stays empty.
170
+ autoPickCount++;
171
+ logAutoPick(pruned.length > 0 ? String(pruned[0]) : firstOption);
134
172
  onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
135
173
  }
136
174
  } else if (
137
- value == null ||
138
- value === '' ||
139
- (typeof value === 'string' && !validValues.has(value))
175
+ (typeof value === 'string' && value && !validValues.has(value)) ||
176
+ ((value == null || value === '') && !userTouched)
140
177
  ) {
141
- // Stale or never-selected single value — fall back to the first option.
178
+ // Stale single value, or never-selected — fall back to the first option.
179
+ autoPickCount++;
180
+ logAutoPick(firstOption);
142
181
  onChange(item.paramId, firstOption, true);
143
182
  }
144
183
  });
145
184
 
146
185
  function commit(newValue: SupportedTypes) {
186
+ userTouched = true;
187
+ // A real user pick re-arms the system fallback (see autoPickCount above).
188
+ autoPickCount = 0;
147
189
  value = newValue;
148
190
  onChange(item.paramId, newValue);
149
191
  }
@@ -12,6 +12,7 @@ import {
12
12
  makeInitialFlags,
13
13
  applyValueChange,
14
14
  applySolveResult,
15
+ pickInputValues,
15
16
  type SolveSessionState
16
17
  } from './solve-session-core';
17
18
 
@@ -75,7 +76,9 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
75
76
  });
76
77
 
77
78
  function dispatch() {
78
- args.driver.solve($state.snapshot(state.values));
79
+ // Input values only: outputs merged into state.values (for widgets that read
80
+ // them, e.g. dynamic value lists) must not be echoed back to the transport.
81
+ args.driver.solve(pickInputValues(currentSchema, $state.snapshot(state.values)));
79
82
  }
80
83
 
81
84
  return {
@@ -67,4 +67,19 @@ describe('createSolveSession.setValue', () => {
67
67
  session.setValue('a', 'y', true);
68
68
  expect(driver.solves.length).toBe(1);
69
69
  });
70
+
71
+ it('never echoes output-keyed values back to the driver', () => {
72
+ const driver = recordingDriver();
73
+ const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
74
+ // A solve result merges outputs into the session's values map (how widgets
75
+ // like dynamic value lists read them) — e.g. a multi-MB options payload.
76
+ session.report({ outputs: { out: { options: { huge: 'payload' } } } });
77
+ session.setValue('a', 'y');
78
+ expect(driver.solves.length).toBe(1);
79
+ expect(driver.solves[0].a).toBe('y');
80
+ // The output entry lives in session.values for widgets…
81
+ expect(session.values.out).toBeDefined();
82
+ // …but must not travel back through the transport.
83
+ expect(driver.solves[0]).not.toHaveProperty('out');
84
+ });
70
85
  });
@@ -85,6 +85,26 @@ export function applyValueChange(
85
85
  return { state, shouldSolve: true };
86
86
  }
87
87
 
88
+ /**
89
+ * Projects the session's live values down to solve INPUTS. Solve outputs are merged
90
+ * into the same values map after each solve (applySolveResult) so widgets like
91
+ * dynamic value lists can read them — but they are not solve inputs, and echoing
92
+ * them back to the driver re-uploads potentially MB-sized payloads (a measured
93
+ * 6.4 MB options list) that no backend reads. Every transport gets this projection
94
+ * for free by going through the session's dispatch.
95
+ */
96
+ export function pickInputValues(
97
+ schema: UISchema | undefined,
98
+ values: Record<string, unknown>
99
+ ): Record<string, unknown> {
100
+ if (!schema?.inputs) return values;
101
+ const picked: Record<string, unknown> = {};
102
+ for (const input of schema.inputs) {
103
+ if (input.id in values) picked[input.id] = values[input.id];
104
+ }
105
+ return picked;
106
+ }
107
+
88
108
  /**
89
109
  * Merges a reported solve result into the state and clears the post-solve lifecycle
90
110
  * flags. Missing result arrays are treated as empty.
@@ -91,6 +91,23 @@ describe('buildDynamicValueListOptions', () => {
91
91
  expect(result[TARGET]).toEqual({ A: '1' });
92
92
  });
93
93
 
94
+ it('returns the SAME parsed object for repeated large string payloads (memoization)', () => {
95
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
96
+ // Above the 1024-char memoization threshold — the expensive compute-mode path.
97
+ const bigOptions: Record<string, string> = {};
98
+ for (let i = 0; i < 100; i++) bigOptions[`option-${i}-${'x'.repeat(20)}`] = String(i);
99
+ const str = JSON.stringify(payload(TARGET, bigOptions));
100
+
101
+ const first = buildDynamicValueListOptions(schema, { [BAKE]: str });
102
+ // A later solve delivering an identical (even newly-allocated) string must yield
103
+ // the same object reference — referential stability is what stops the dropdown
104
+ // subtree from re-rendering on every unrelated values change.
105
+ const second = buildDynamicValueListOptions(schema, { [BAKE]: String(str) });
106
+
107
+ expect(first[TARGET]).toEqual(bigOptions);
108
+ expect(second[TARGET]).toBe(first[TARGET]);
109
+ });
110
+
94
111
  it('dedupes outputs[] over layout for the same id', () => {
95
112
  const schema = schemaWith({
96
113
  outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: 'from-outputs' }] as never,
@@ -40,6 +40,47 @@ function coercePayload(value: unknown): DynamicValueListPayload | null {
40
40
  return null;
41
41
  }
42
42
 
43
+ // Memoizes string-payload coercion. In compute mode the payload arrives as a JSON
44
+ // string — potentially several MB for a large options list — and the options map is
45
+ // derived from `values` (TabLayout), so it recomputes on EVERY value change. Without
46
+ // memoization each keystroke/output-merge re-parses megabytes and allocates a fresh
47
+ // options object, whose new identity re-renders the entire dropdown subtree; with a
48
+ // 6.4 MB payload this was measured driving a tab out of memory. Map keys use
49
+ // SameValueZero, so an identical response string from a later solve also hits.
50
+ const coerceCache = new Map<string, DynamicValueListPayload | null>();
51
+ const COERCE_CACHE_MAX = 8;
52
+
53
+ function coercePayloadMemo(value: unknown): DynamicValueListPayload | null {
54
+ // Only string payloads are expensive (JSON.parse); objects pass straight through.
55
+ if (typeof value !== 'string' || value.length < 1024) return coercePayload(value);
56
+ const hit = coerceCache.get(value);
57
+ if (hit !== undefined || coerceCache.has(value)) {
58
+ // Refresh LRU position.
59
+ coerceCache.delete(value);
60
+ coerceCache.set(value, hit ?? null);
61
+ return hit ?? null;
62
+ }
63
+ const parseStart = performance.now();
64
+ const parsed = coercePayload(value);
65
+ // Diagnostic: an expensive parse should happen ONCE per distinct solve result.
66
+ // If this line storms in the console, payload memoization is being defeated
67
+ // (e.g. payload strings differing per recompute) — the pre-memoization churn
68
+ // pattern that could OOM a tab.
69
+ if (value.length > 256 * 1024) {
70
+ const optionCount = parsed?.options ? Object.keys(parsed.options).length : 0;
71
+ console.info(
72
+ `[DVL] parsed ${(value.length / (1024 * 1024)).toFixed(1)} MB options payload ` +
73
+ `(${optionCount} options) in ${(performance.now() - parseStart).toFixed(0)}ms — cache miss`
74
+ );
75
+ }
76
+ if (coerceCache.size >= COERCE_CACHE_MAX) {
77
+ const oldest = coerceCache.keys().next().value;
78
+ if (oldest !== undefined) coerceCache.delete(oldest);
79
+ }
80
+ coerceCache.set(value, parsed);
81
+ return parsed;
82
+ }
83
+
43
84
  /** One DynVL routing source: the id keying `values`, plus the schema-side target fallback. */
44
85
  interface DynamicValueListSource {
45
86
  id: string;
@@ -87,7 +128,7 @@ export function buildDynamicValueListOptions(
87
128
  const result: Record<string, Record<string, string>> = {};
88
129
 
89
130
  for (const source of collectDynamicValueListSources(schema)) {
90
- const payload = coercePayload(values[source.id]);
131
+ const payload = coercePayloadMemo(values[source.id]);
91
132
  if (!payload) continue;
92
133
 
93
134
  const targetInputId = payload.targetInputId ?? source.targetInputId;