@selvajs/ui 4.12.0 → 4.12.2

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.
@@ -115,28 +115,40 @@
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
+
118
123
  $effect(() => {
119
124
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
120
125
  const validValues = new Set(Object.values(dynamicListOptions));
126
+ const firstOption = Object.values(dynamicListOptions)[0];
121
127
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
122
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.
123
134
  if (Array.isArray(value)) {
124
135
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
125
- if (pruned.length !== value.length) {
126
- // Fall back to first option when checklist becomes fully empty after pruning
127
- onChange(
128
- item.paramId,
129
- pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
130
- true
131
- );
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.
139
+ onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
132
140
  }
133
- } else if (typeof value === 'string' && value && !validValues.has(value)) {
134
- // Fall back to first available option instead of clearing to empty
135
- onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
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.
146
+ onChange(item.paramId, firstOption, true);
136
147
  }
137
148
  });
138
149
 
139
150
  function commit(newValue: SupportedTypes) {
151
+ userTouched = true;
140
152
  value = newValue;
141
153
  onChange(item.paramId, newValue);
142
154
  }
@@ -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
- args.driver.solve($state.snapshot(state.values));
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.12.0",
3
+ "version": "4.12.2",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -37,7 +37,7 @@
37
37
  "**/*.css"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@selvajs/compute": "^3.0.0-beta.1",
40
+ "@selvajs/compute": "^3.0.1",
41
41
  "@sveltejs/kit": "^2",
42
42
  "bits-ui": "^2.18.0",
43
43
  "svelte": "^5",
@@ -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/schemas": "4.6.1",
76
- "@selvajs/config": "0.0.2"
75
+ "@selvajs/config": "0.0.2",
76
+ "@selvajs/schemas": "4.6.1"
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "vite dev",
@@ -115,28 +115,40 @@
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
+
118
123
  $effect(() => {
119
124
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
120
125
  const validValues = new Set(Object.values(dynamicListOptions));
126
+ const firstOption = Object.values(dynamicListOptions)[0];
121
127
  // Route through onChange (not commit) — value is a one-way prop here, so writing it
122
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.
123
134
  if (Array.isArray(value)) {
124
135
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
125
- if (pruned.length !== value.length) {
126
- // Fall back to first option when checklist becomes fully empty after pruning
127
- onChange(
128
- item.paramId,
129
- pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
130
- true
131
- );
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.
139
+ onChange(item.paramId, pruned.length > 0 ? pruned : [firstOption], true);
132
140
  }
133
- } else if (typeof value === 'string' && value && !validValues.has(value)) {
134
- // Fall back to first available option instead of clearing to empty
135
- onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
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.
146
+ onChange(item.paramId, firstOption, true);
136
147
  }
137
148
  });
138
149
 
139
150
  function commit(newValue: SupportedTypes) {
151
+ userTouched = true;
140
152
  value = newValue;
141
153
  onChange(item.paramId, newValue);
142
154
  }
@@ -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
- args.driver.solve($state.snapshot(state.values));
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.