@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.
@@ -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 (or empty)
114
- // instead of rendering the orphaned raw value as its own label. This is a system-initiated
115
- // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
116
- // schemas would keep the prior output on screen, making it look like the auto-picked option
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
-
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
- if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
137
- // Fall back to first option when the checklist is fully pruned or was
138
- // never selected; a user-cleared checklist stays empty.
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
- (typeof value === 'string' && value && !validValues.has(value)) ||
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 = 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.4",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -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 (or empty)
114
- // instead of rendering the orphaned raw value as its own label. This is a system-initiated
115
- // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
116
- // schemas would keep the prior output on screen, making it look like the auto-picked option
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
-
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
- if (pruned.length !== value.length || (value.length === 0 && !userTouched)) {
137
- // Fall back to first option when the checklist is fully pruned or was
138
- // never selected; a user-cleared checklist stays empty.
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
- (typeof value === 'string' && value && !validValues.has(value)) ||
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 = 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;