@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.
- package/dist/components/preview/InputControl.svelte +37 -0
- package/dist/schema/dynamic-value-list.js +40 -1
- package/package.json +1 -1
- package/src/lib/components/preview/InputControl.svelte +37 -0
- package/src/lib/schema/dynamic-value-list.test.ts +17 -0
- package/src/lib/schema/dynamic-value-list.ts +42 -1
|
@@ -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 =
|
|
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
|
@@ -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 =
|
|
131
|
+
const payload = coercePayloadMemo(values[source.id]);
|
|
91
132
|
if (!payload) continue;
|
|
92
133
|
|
|
93
134
|
const targetInputId = payload.targetInputId ?? source.targetInputId;
|