@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.
- package/dist/components/preview/InputControl.svelte +49 -7
- package/dist/compute/createSolveSession.svelte.js +4 -2
- package/dist/compute/solve-session-core.d.ts +9 -0
- package/dist/compute/solve-session-core.js +18 -0
- package/dist/schema/dynamic-value-list.js +40 -1
- package/package.json +3 -3
- package/src/lib/components/preview/InputControl.svelte +49 -7
- package/src/lib/compute/createSolveSession.svelte.ts +4 -1
- package/src/lib/compute/createSolveSession.test.ts +15 -0
- package/src/lib/compute/solve-session-core.ts +20 -0
- package/src/lib/schema/dynamic-value-list.test.ts +17 -0
- package/src/lib/schema/dynamic-value-list.ts +42 -1
|
@@ -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 (
|
|
132
|
-
// Fall back to first option when the checklist is
|
|
133
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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/
|
|
76
|
-
"@selvajs/
|
|
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 (
|
|
132
|
-
// Fall back to first option when the checklist is
|
|
133
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
131
|
+
const payload = coercePayloadMemo(values[source.id]);
|
|
91
132
|
if (!payload) continue;
|
|
92
133
|
|
|
93
134
|
const targetInputId = payload.targetInputId ?? source.targetInputId;
|