@selvajs/ui 4.12.2 → 4.12.4
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 +15 -26
- package/dist/schema/dynamic-value-list.js +40 -1
- package/package.json +1 -1
- package/src/lib/components/preview/InputControl.svelte +15 -26
- package/src/lib/schema/dynamic-value-list.test.ts +17 -0
- package/src/lib/schema/dynamic-value-list.ts +42 -1
|
@@ -110,45 +110,34 @@
|
|
|
110
110
|
);
|
|
111
111
|
|
|
112
112
|
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
113
|
-
// available option. Prune the stale selection so the control shows a valid option
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
// available option. Prune the stale selection so the control shows a valid option instead
|
|
114
|
+
// of rendering the orphaned raw value as its own label. This is a system-initiated change
|
|
115
|
+
// (the user didn't pick the new option), so force a solve — otherwise manual-solve schemas
|
|
116
|
+
// would keep the prior output on screen, making it look like the auto-picked option produced it.
|
|
117
|
+
//
|
|
118
|
+
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. There
|
|
119
|
+
// is always at least one option, and an empty selection reaches the definition as null/""
|
|
120
|
+
// — which throws NREs in downstream geometry components (e.g. Bounding Rectangle) and nulls
|
|
121
|
+
// every output beyond them. So every terminal state below resolves to a valid option; there
|
|
122
|
+
// is deliberately no "user cleared it, stay empty" path.
|
|
123
123
|
$effect(() => {
|
|
124
124
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
125
125
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
126
126
|
const firstOption = Object.values(dynamicListOptions)[0];
|
|
127
|
-
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
128
|
-
// directly from an effect trips Svelte's binding-ownership check.
|
|
129
|
-
// A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
|
|
130
|
-
// gets the same first-option fallback as a stale one: an empty selection solves
|
|
131
|
-
// as an empty string, which cascades through the definition as null-data errors
|
|
132
|
-
// ("File not found: .dmf", Text→Number conversion failures, …) and the empty
|
|
133
|
-
// result then gets replayed by the solve caches.
|
|
134
127
|
if (Array.isArray(value)) {
|
|
135
128
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
129
|
+
// Empty (never selected or fully pruned) always falls back to the first option —
|
|
130
|
+
// a checklist that solves empty produces the same null cascade as a single value.
|
|
131
|
+
if (pruned.length !== value.length || value.length === 0) {
|
|
139
132
|
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
140
133
|
}
|
|
141
|
-
} else if (
|
|
142
|
-
|
|
143
|
-
((value == null || value === '') && !userTouched)
|
|
144
|
-
) {
|
|
145
|
-
// Stale single value, or never-selected — fall back to the first option.
|
|
134
|
+
} else if (typeof value !== 'string' || value === '' || !validValues.has(value)) {
|
|
135
|
+
// Never-selected or stale single value — fall back to the first option.
|
|
146
136
|
onChange(item.paramId, firstOption, true);
|
|
147
137
|
}
|
|
148
138
|
});
|
|
149
139
|
|
|
150
140
|
function commit(newValue: SupportedTypes) {
|
|
151
|
-
userTouched = true;
|
|
152
141
|
value = newValue;
|
|
153
142
|
onChange(item.paramId, newValue);
|
|
154
143
|
}
|
|
@@ -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
|
@@ -110,45 +110,34 @@
|
|
|
110
110
|
);
|
|
111
111
|
|
|
112
112
|
// When a dynamic value list recomputes, a previously-selected value may no longer be an
|
|
113
|
-
// available option. Prune the stale selection so the control shows a valid option
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
// available option. Prune the stale selection so the control shows a valid option instead
|
|
114
|
+
// of rendering the orphaned raw value as its own label. This is a system-initiated change
|
|
115
|
+
// (the user didn't pick the new option), so force a solve — otherwise manual-solve schemas
|
|
116
|
+
// would keep the prior output on screen, making it look like the auto-picked option produced it.
|
|
117
|
+
//
|
|
118
|
+
// INVARIANT: a dynamic value list must never dispatch an empty/null value to solve. There
|
|
119
|
+
// is always at least one option, and an empty selection reaches the definition as null/""
|
|
120
|
+
// — which throws NREs in downstream geometry components (e.g. Bounding Rectangle) and nulls
|
|
121
|
+
// every output beyond them. So every terminal state below resolves to a valid option; there
|
|
122
|
+
// is deliberately no "user cleared it, stay empty" path.
|
|
123
123
|
$effect(() => {
|
|
124
124
|
if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
|
|
125
125
|
const validValues = new Set(Object.values(dynamicListOptions));
|
|
126
126
|
const firstOption = Object.values(dynamicListOptions)[0];
|
|
127
|
-
// Route through onChange (not commit) — value is a one-way prop here, so writing it
|
|
128
|
-
// directly from an effect trips Svelte's binding-ownership check.
|
|
129
|
-
// A NEVER-made selection (empty string/array, e.g. no default on a fresh page)
|
|
130
|
-
// gets the same first-option fallback as a stale one: an empty selection solves
|
|
131
|
-
// as an empty string, which cascades through the definition as null-data errors
|
|
132
|
-
// ("File not found: .dmf", Text→Number conversion failures, …) and the empty
|
|
133
|
-
// result then gets replayed by the solve caches.
|
|
134
127
|
if (Array.isArray(value)) {
|
|
135
128
|
const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
129
|
+
// Empty (never selected or fully pruned) always falls back to the first option —
|
|
130
|
+
// a checklist that solves empty produces the same null cascade as a single value.
|
|
131
|
+
if (pruned.length !== value.length || value.length === 0) {
|
|
139
132
|
onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
|
|
140
133
|
}
|
|
141
|
-
} else if (
|
|
142
|
-
|
|
143
|
-
((value == null || value === '') && !userTouched)
|
|
144
|
-
) {
|
|
145
|
-
// Stale single value, or never-selected — fall back to the first option.
|
|
134
|
+
} else if (typeof value !== 'string' || value === '' || !validValues.has(value)) {
|
|
135
|
+
// Never-selected or stale single value — fall back to the first option.
|
|
146
136
|
onChange(item.paramId, firstOption, true);
|
|
147
137
|
}
|
|
148
138
|
});
|
|
149
139
|
|
|
150
140
|
function commit(newValue: SupportedTypes) {
|
|
151
|
-
userTouched = true;
|
|
152
141
|
value = newValue;
|
|
153
142
|
onChange(item.paramId, newValue);
|
|
154
143
|
}
|
|
@@ -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;
|