@selvajs/ui 4.12.2 → 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.
@@ -120,10 +120,34 @@
120
120
  // every checklist entry), only fill the initial void.
121
121
  let userTouched = false;
122
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
+
123
131
  $effect(() => {
124
132
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
125
133
  const validValues = new Set(Object.values(dynamicListOptions));
126
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
+ }
127
151
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
128
152
  // directly from an effect trips Svelte's binding-ownership check.
129
153
  // A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
@@ -131,11 +155,20 @@
131
155
  // as an empty string, which cascades through the definition as null-data errors
132
156
  // ("File not found: .dmf", Text→Number conversion failures, …) and the empty
133
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
+ );
134
165
  if (Array.isArray(value)) {
135
166
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
136
167
  if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
137
168
  // Fall back to first option when the checklist is fully pruned or was
138
169
  // never selected; a user-cleared checklist stays empty.
170
+ autoPickCount++;
171
+ logAutoPick(pruned.length > 0 ? String(pruned[0]) : firstOption);
139
172
  onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
140
173
  }
141
174
  } else if (
@@ -143,12 +176,16 @@
143
176
  ((value == null || value === '') && !userTouched)
144
177
  ) {
145
178
  // Stale single value, or never-selected — fall back to the first option.
179
+ autoPickCount++;
180
+ logAutoPick(firstOption);
146
181
  onChange(item.paramId, firstOption, true);
147
182
  }
148
183
  });
149
184
 
150
185
  function commit(newValue: SupportedTypes) {
151
186
  userTouched = true;
187
+ // A real user pick re-arms the system fallback (see autoPickCount above).
188
+ autoPickCount = 0;
152
189
  value = newValue;
153
190
  onChange(item.paramId, newValue);
154
191
  }
@@ -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.2",
3
+ "version": "4.12.3",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -120,10 +120,34 @@
120
120
  // every checklist entry), only fill the initial void.
121
121
  let userTouched = false;
122
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
+
123
131
  $effect(() => {
124
132
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
125
133
  const validValues = new Set(Object.values(dynamicListOptions));
126
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
+ }
127
151
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
128
152
  // directly from an effect trips Svelte's binding-ownership check.
129
153
  // A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
@@ -131,11 +155,20 @@
131
155
  // as an empty string, which cascades through the definition as null-data errors
132
156
  // ("File not found: .dmf", Text→Number conversion failures, …) and the empty
133
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
+ );
134
165
  if (Array.isArray(value)) {
135
166
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
136
167
  if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
137
168
  // Fall back to first option when the checklist is fully pruned or was
138
169
  // never selected; a user-cleared checklist stays empty.
170
+ autoPickCount++;
171
+ logAutoPick(pruned.length > 0 ? String(pruned[0]) : firstOption);
139
172
  onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
140
173
  }
141
174
  } else if (
@@ -143,12 +176,16 @@
143
176
  ((value == null || value === '') && !userTouched)
144
177
  ) {
145
178
  // Stale single value, or never-selected — fall back to the first option.
179
+ autoPickCount++;
180
+ logAutoPick(firstOption);
146
181
  onChange(item.paramId, firstOption, true);
147
182
  }
148
183
  });
149
184
 
150
185
  function commit(newValue: SupportedTypes) {
151
186
  userTouched = true;
187
+ // A real user pick re-arms the system fallback (see autoPickCount above).
188
+ autoPickCount = 0;
152
189
  value = newValue;
153
190
  onChange(item.paramId, newValue);
154
191
  }
@@ -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;