flet-box 1.0.3 → 1.0.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.
Files changed (42) hide show
  1. package/package.json +3 -2
  2. package/src/animations/AnimatedBox.js +32 -1
  3. package/src/animations/AnimatedText.js +7 -3
  4. package/src/animations/MatrixRain.js +37 -12
  5. package/src/animations/ParallaxBox.js +16 -3
  6. package/src/animations/animate.js +22 -3
  7. package/src/core/runApp.js +13 -1
  8. package/src/index.d.ts +191 -78
  9. package/src/navigations/AppBar.js +8 -2
  10. package/src/navigations/BottomNavigation.js +14 -2
  11. package/src/navigations/DrawerItem.js +16 -3
  12. package/src/navigations/Scaffold.js +8 -1
  13. package/src/navigations/Tabs.js +34 -6
  14. package/src/tools/dimensions.js +11 -3
  15. package/src/tools/useState.js +115 -68
  16. package/src/utils/getWidgetProps.js +5 -3
  17. package/src/widget-factory/assignProps.js +7 -10
  18. package/src/widget-factory/processProps.js +7 -2
  19. package/src/widget-factory/reactiveValue.js +147 -0
  20. package/src/widget-factory/warnings.js +15 -0
  21. package/src/widget-factory/widgetFactory.js +27 -0
  22. package/src/widgets/Accordion.js +6 -5
  23. package/src/widgets/Audio.js +30 -8
  24. package/src/widgets/BottomSheet.js +24 -1
  25. package/src/widgets/Chart.js +4 -1
  26. package/src/widgets/Checkbox.js +7 -1
  27. package/src/widgets/CircularChart.js +4 -1
  28. package/src/widgets/CodeViewer.js +26 -8
  29. package/src/widgets/DataTable.js +11 -2
  30. package/src/widgets/Dropdown.js +5 -1
  31. package/src/widgets/Image.js +8 -1
  32. package/src/widgets/Input.js +27 -6
  33. package/src/widgets/Inspector.js +16 -10
  34. package/src/widgets/ListView.js +7 -1
  35. package/src/widgets/Markdown.js +53 -8
  36. package/src/widgets/Modal.js +24 -1
  37. package/src/widgets/QRCode.js +9 -2
  38. package/src/widgets/Radio.js +5 -1
  39. package/src/widgets/Slider.js +3 -0
  40. package/src/widgets/SnackBar.js +20 -2
  41. package/src/widgets/Switch.js +5 -1
  42. package/src/widgets/TreeView.js +3 -1
@@ -277,9 +277,15 @@ export const AppBar = (props) => {
277
277
  lastScrollY = currentScrollY;
278
278
  };
279
279
  window.addEventListener("scroll", handleScroll);
280
- // Store the remover so _cleanup can deregister the listener.
281
- appBar._cleanupScroll = () =>
280
+ // Register the remover through BOTH teardown paths. `_cleanup` is only called
281
+ // by runApp's teardownTree / Scaffold, while a plain DOM disconnect goes
282
+ // through the lifecycle observer, which never calls `_cleanup` — so without
283
+ // `onUnmount` this listener (and the detached bar) stayed alive forever.
284
+ // `removeEventListener` is idempotent, so running both is harmless.
285
+ const removeScroll = () =>
282
286
  window.removeEventListener("scroll", handleScroll);
287
+ appBar._cleanupScroll = removeScroll;
288
+ appBar.onUnmount(removeScroll);
283
289
  }
284
290
 
285
291
  // ========== PUBLIC API ==========
@@ -285,10 +285,22 @@ export const BottomNavigation = (props) => {
285
285
  return getCurrentPath();
286
286
  };
287
287
 
288
- // Cleanup: unsubscribe from the router when the bar is removed from the DOM.
288
+ // Cleanup: release the router subscription on EITHER teardown path. `_cleanup`
289
+ // is only run by runApp's teardownTree / Scaffold; the lifecycle observer that
290
+ // fires on a plain DOM disconnect never calls it, so without `onUnmount` the
291
+ // router kept invoking `updateColorsFromRouter()` (and `onTabChange`) on a
292
+ // detached bar forever. The disposer is idempotent because both paths can run.
293
+ let unsubscribed = false;
294
+ const dispose = () => {
295
+ if (unsubscribed) return;
296
+ unsubscribed = true;
297
+ if (unsubscribe) unsubscribe();
298
+ };
299
+ container.onUnmount(dispose);
300
+
289
301
  const originalCleanup = container._cleanup;
290
302
  container._cleanup = () => {
291
- if (unsubscribe) unsubscribe();
303
+ dispose();
292
304
  if (originalCleanup) originalCleanup();
293
305
  };
294
306
 
@@ -246,11 +246,24 @@ export const DrawerItem = (props) => {
246
246
  // Apply the initial visual state now that DOM refs are set.
247
247
  updateUI();
248
248
 
249
- // Cleanup: unsubscribe from router and selection events to prevent memory leaks.
250
- const originalCleanup = container._cleanup;
251
- container._cleanup = () => {
249
+ // Cleanup: release the router subscription and the selection listener on EITHER
250
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; the
251
+ // lifecycle observer that fires on a plain DOM disconnect never calls it, so
252
+ // without `onUnmount` every removed item kept a live router callback (retaining
253
+ // its DOM refs) plus a listener on the shared selection EventTarget. The
254
+ // disposer is idempotent because both paths can run.
255
+ let disposed = false;
256
+ const dispose = () => {
257
+ if (disposed) return;
258
+ disposed = true;
252
259
  if (unsubscribeRouter) unsubscribeRouter();
253
260
  if (unsubscribeGlobal) unsubscribeGlobal();
261
+ };
262
+ container.onUnmount(dispose);
263
+
264
+ const originalCleanup = container._cleanup;
265
+ container._cleanup = () => {
266
+ dispose();
254
267
  if (originalCleanup) originalCleanup();
255
268
  };
256
269
 
@@ -494,8 +494,15 @@ export const Scaffold = (props) => {
494
494
  clearContainer(fabContainer);
495
495
  clearContainer(leftNavBarContainer);
496
496
  clearContainer(rightNavBarContainer);
497
- if (container._drawer?.parentNode)
497
+ // `destroy()` releases the viewport-resize listener, the Escape key handler
498
+ // and the pending close timer, then removes the node itself. Removing only
499
+ // the node (the previous behaviour) left all three behind, so every rebuilt
500
+ // scaffold leaked one more listener and one more body child.
501
+ if (typeof container._drawer?.destroy === "function") {
502
+ container._drawer.destroy();
503
+ } else if (container._drawer?.parentNode) {
498
504
  container._drawer.parentNode.removeChild(container._drawer);
505
+ }
499
506
  if (originalCleanup) originalCleanup();
500
507
  };
501
508
 
@@ -318,6 +318,11 @@ export const Tabs = (props) => {
318
318
  updateContent();
319
319
  };
320
320
 
321
+ // Mount the initially active pane during construction. Without this the pane
322
+ // area stayed empty until the user clicked a *different* tab, even though the
323
+ // first tab already rendered as `aria-selected="true"`.
324
+ updateContent();
325
+
321
326
  // Defer the initial slider position calculation by one frame so the DOM
322
327
  // has been laid out and getBoundingClientRect() returns real values.
323
328
  let initialPositionTimer = setTimeout(() => {
@@ -341,18 +346,41 @@ export const Tabs = (props) => {
341
346
 
342
347
  // Also observe the tab bar element itself so the thumb repositions when
343
348
  // the container resizes independently of the window (e.g. sidebar toggle).
344
- if (typeof ResizeObserver !== "undefined") {
349
+ // Only the slider variant has a thumb, so skip it for the other variants.
350
+ if (variant === "slider" && typeof ResizeObserver !== "undefined") {
345
351
  resizeObserver = new ResizeObserver(() => updateSliderPosition());
346
352
  if (tabsWrapperRef) resizeObserver.observe(tabsWrapperRef);
347
353
  }
348
354
 
349
- // Cleanup: cancel timers and remove listeners when the component is unmounted.
355
+ // Cleanup: release the timers, the window listener and the observer on EITHER
356
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; a
357
+ // plain DOM disconnect goes through the lifecycle observer, which never calls
358
+ // it — so without `onUnmount` the resize listener stayed registered and the
359
+ // ResizeObserver kept observing (and retaining) a detached tab bar. The
360
+ // disposer is idempotent because both paths can run.
361
+ let disposed = false;
362
+ const dispose = () => {
363
+ if (disposed) return;
364
+ disposed = true;
365
+ if (initialPositionTimer) {
366
+ clearTimeout(initialPositionTimer);
367
+ initialPositionTimer = null;
368
+ }
369
+ if (resizeTimeout) {
370
+ clearTimeout(resizeTimeout);
371
+ resizeTimeout = null;
372
+ }
373
+ window.removeEventListener("resize", handleResize);
374
+ if (resizeObserver) {
375
+ resizeObserver.disconnect();
376
+ resizeObserver = null;
377
+ }
378
+ };
379
+ container.onUnmount(dispose);
380
+
350
381
  const originalCleanup = container._cleanup;
351
382
  container._cleanup = () => {
352
- if (initialPositionTimer) clearTimeout(initialPositionTimer);
353
- if (resizeTimeout) clearTimeout(resizeTimeout);
354
- window.removeEventListener("resize", handleResize);
355
- if (resizeObserver) resizeObserver.disconnect();
383
+ dispose();
356
384
  if (originalCleanup) originalCleanup();
357
385
  };
358
386
 
@@ -16,8 +16,14 @@
16
16
  * // Call remove() to stop listening.
17
17
  */
18
18
 
19
- let windowWidth = window.innerWidth;
20
- let windowHeight = window.innerHeight;
19
+ // SSR / Node guard: this module must stay importable where `window` does not
20
+ // exist (SSR, test runners, build tooling). Reading `window.innerWidth` at
21
+ // module scope used to throw a ReferenceError on import. Same convention as
22
+ // `src/core/pwa.js`.
23
+ const hasWindow = typeof window !== "undefined";
24
+
25
+ let windowWidth = hasWindow ? window.innerWidth : 0;
26
+ let windowHeight = hasWindow ? window.innerHeight : 0;
21
27
 
22
28
  // Live named exports — is-module live bindings: importers of { width, height }
23
29
  // always read the current value, kept in sync on resize.
@@ -45,7 +51,9 @@ const handleResize = () => {
45
51
  });
46
52
  };
47
53
 
48
- window.addEventListener("resize", handleResize);
54
+ // Only subscribe inside a browser; in Node the listener list stays inert and
55
+ // `addListener` / `removeListener` keep working without throwing.
56
+ if (hasWindow) window.addEventListener("resize", handleResize);
49
57
 
50
58
  /**
51
59
  * Dimensions object — provides getter properties and listener management.
@@ -1,5 +1,6 @@
1
1
  // src/tools/useState.js
2
2
  import { saveRam, getRam, subscribeRam } from "../services/RamStore.js";
3
+ import { createReactiveValue, setImperativeReadListener } from "../widget-factory/reactiveValue.js";
3
4
 
4
5
  // Store callbacks for each state key
5
6
  const subscribers = new Map();
@@ -7,6 +8,13 @@ const subscribers = new Map();
7
8
  // 🔥 Store widgets by state key for automatic updates
8
9
  const widgetsByKey = new Map();
9
10
 
11
+ // Keys the component reads *structurally* — inside a template literal, a
12
+ // conditional, a list. Those need the app render even when a widget is bound to
13
+ // the key, because a surgical prop patch cannot change what the component
14
+ // computes. A key passed straight into a prop is never listed here.
15
+ const imperativeKeys = new Set();
16
+ setImperativeReadListener((key) => imperativeKeys.add(key));
17
+
10
18
  // Global render function (set by runApp)
11
19
  let globalRender = null;
12
20
  let renderTimeout = null;
@@ -35,6 +43,56 @@ const triggerRender = () => {
35
43
  }, 0);
36
44
  };
37
45
 
46
+ let ramDispatcherReady = false;
47
+
48
+ /**
49
+ * Installs **one** RamStore subscriber that dispatches to the widgets and
50
+ * callbacks registered for the key that actually changed.
51
+ *
52
+ * RamStore walks a flat subscriber array on every `saveRam`, so registering one
53
+ * subscriber per key made a single state change cost O(number of keys) — with
54
+ * every callback immediately rejecting the key. One dispatcher makes the lookup
55
+ * O(1), and the work proportional to what the key really has bound.
56
+ *
57
+ * @returns {void}
58
+ */
59
+ const ensureRamDispatcher = () => {
60
+ if (ramDispatcherReady) return;
61
+ ramDispatcherReady = true;
62
+
63
+ const unsubscribe = subscribeRam((changedKey, newValue, oldValue) => {
64
+ const callbacks = subscribers.get(changedKey);
65
+ // A key nobody ever passed to `useState` has no listeners: ignore it.
66
+ if (!callbacks) return;
67
+
68
+ // 1. Surgical update: patch only the widgets bound to this key.
69
+ const bound = widgetsByKey.get(changedKey);
70
+ let handled = 0;
71
+ if (bound) {
72
+ bound.forEach((propNames, widget) => {
73
+ if (!widget || typeof widget.update !== "function") return;
74
+ propNames.forEach((propName) => {
75
+ widget.update({ [propName]: newValue });
76
+ handled++;
77
+ });
78
+ });
79
+ }
80
+
81
+ // 2. Notify the manual subscribers.
82
+ callbacks.forEach((callback) => callback(newValue, oldValue));
83
+
84
+ // 3. Re-render the app when nothing was bound, or when the component also
85
+ // reads this key structurally (a template literal, a conditional, a list):
86
+ // the surgical patch above cannot change what the component computes.
87
+ if (handled === 0 || imperativeKeys.has(changedKey)) triggerRender();
88
+ });
89
+
90
+ if (typeof window !== "undefined") {
91
+ if (!window._useStateCleanups) window._useStateCleanups = [];
92
+ window._useStateCleanups.push(unsubscribe);
93
+ }
94
+ };
95
+
38
96
  /**
39
97
  * Registers a widget instance so it receives automatic prop updates whenever
40
98
  * the state value associated with `key` changes.
@@ -45,31 +103,54 @@ const triggerRender = () => {
45
103
  * @returns {void}
46
104
  */
47
105
  export const registerWidget = (key, widget, propName) => {
48
- if (!widgetsByKey.has(key)) {
49
- widgetsByKey.set(key, new Set());
106
+ // Two levels — key -> widget -> prop names — so looking a widget up is O(1).
107
+ // A flat Set with a linear scan made registering N widgets on the same key
108
+ // O(N^2): 1,600 widgets on one key cost ~44 ms.
109
+ let perWidget = widgetsByKey.get(key);
110
+ if (!perWidget) {
111
+ perWidget = new Map();
112
+ widgetsByKey.set(key, perWidget);
113
+ }
114
+ let propNames = perWidget.get(widget);
115
+ if (!propNames) {
116
+ propNames = new Set();
117
+ perWidget.set(widget, propNames);
50
118
  }
51
- widgetsByKey.get(key).add({ widget, propName });
119
+ // Idempotent: rebuilding the same widget position must not stack duplicates.
120
+ const already = propNames.has(propName);
121
+ propNames.add(propName);
52
122
 
53
- // Set initial value if the state already exists
123
+ // Push the current value straight away when the widget can already take it.
124
+ // (During construction `update` does not exist yet — the value has just been
125
+ // applied by the factory, so there is nothing to push.)
54
126
  const currentValue = getRam(key);
55
127
  if (currentValue !== undefined && widget && widget.update) {
56
128
  widget.update({ [propName]: currentValue });
57
129
  }
58
130
 
59
- // Patch the widget's cleanup to also remove it from the registry when destroyed
60
- const originalCleanup = widget._cleanup;
61
- widget._cleanup = () => {
62
- const widgets = widgetsByKey.get(key);
63
- if (widgets) {
64
- for (const item of widgets) {
65
- if (item.widget === widget) {
66
- widgets.delete(item);
67
- break;
68
- }
69
- }
70
- }
71
- if (originalCleanup) originalCleanup();
131
+ if (already) return;
132
+
133
+ // Release the binding through `onUnmount`, which the framework fires on BOTH
134
+ // teardown paths. Patching `_cleanup` here (the previous approach) leaked: the
135
+ // lifecycle observer never calls `_cleanup`, so a widget removed without a full
136
+ // app rebuild stayed registered forever and kept receiving updates.
137
+ const release = () => {
138
+ const map = widgetsByKey.get(key);
139
+ const props = map && map.get(widget);
140
+ if (!props) return;
141
+ props.delete(propName);
142
+ if (props.size === 0) map.delete(widget);
72
143
  };
144
+
145
+ if (typeof widget.onUnmount === "function") {
146
+ widget.onUnmount(release);
147
+ } else {
148
+ const originalCleanup = widget._cleanup;
149
+ widget._cleanup = () => {
150
+ release();
151
+ if (originalCleanup) originalCleanup();
152
+ };
153
+ }
73
154
  };
74
155
 
75
156
  /**
@@ -80,15 +161,8 @@ export const registerWidget = (key, widget, propName) => {
80
161
  * @returns {void}
81
162
  */
82
163
  export const unregisterWidget = (key, widget) => {
83
- const widgets = widgetsByKey.get(key);
84
- if (widgets) {
85
- for (const item of widgets) {
86
- if (item.widget === widget) {
87
- widgets.delete(item);
88
- break;
89
- }
90
- }
91
- }
164
+ const perWidget = widgetsByKey.get(key);
165
+ if (perWidget) perWidget.delete(widget);
92
166
  };
93
167
 
94
168
  /**
@@ -117,36 +191,13 @@ export const useState = (key, initialState, widget = null, propName = null) => {
117
191
  saveRam(key, currentValue);
118
192
  }
119
193
 
120
- // Set up a single RamStore subscriber per key
121
- if (!subscribers.has(key)) {
122
- subscribers.set(key, new Set());
123
-
124
- // Listen to RamStore changes for this key
125
- const unsubscribe = subscribeRam((changedKey, newValue, oldValue) => {
126
- if (changedKey === key) {
127
- // 🔥 Push the new value to all registered widgets for this key
128
- const widgets = widgetsByKey.get(key);
129
- if (widgets) {
130
- widgets.forEach(({ widget, propName }) => {
131
- if (widget && widget.update) {
132
- widget.update({ [propName]: newValue });
133
- }
134
- });
135
- }
136
-
137
- // Notify all subscriber callbacks
138
- const callbacks = subscribers.get(key);
139
- callbacks.forEach((callback) => callback(newValue, oldValue));
140
-
141
- // Trigger a global re-render
142
- triggerRender();
143
- }
144
- });
145
-
146
- // Track the unsubscribe handle for hot-reload cleanup
147
- if (!window._useStateCleanups) window._useStateCleanups = [];
148
- window._useStateCleanups.push(unsubscribe);
149
- }
194
+ // Register the key so the shared dispatcher knows it has listeners. Previously
195
+ // every key installed its OWN RamStore subscriber, and RamStore notifies all of
196
+ // them on every save — so one state change cost O(number of keys), each callback
197
+ // rejecting the key immediately. One dispatcher makes it O(1) + the work for the
198
+ // key that actually changed.
199
+ if (!subscribers.has(key)) subscribers.set(key, new Set());
200
+ ensureRamDispatcher();
150
201
 
151
202
  // 🔥 Register the widget for automatic updates if one was provided
152
203
  if (widget && propName) {
@@ -194,18 +245,11 @@ export const useState = (key, initialState, widget = null, propName = null) => {
194
245
  };
195
246
  };
196
247
 
197
- // 🔥 Create a reactive value object so WidgetFactory can detect state bindings automatically
198
- const reactiveValue = {
199
- _isReactive: true,
200
- _stateKey: key,
201
- _value: currentValue,
202
- valueOf() {
203
- return this._value;
204
- },
205
- toString() {
206
- return String(this._value);
207
- },
208
- };
248
+ // 🔥 A LIVE reactive value: reads the store on every access (so it never goes
249
+ // stale) and carries the key, so WidgetFactory can auto-bind any prop it is
250
+ // passed to. That binding is what makes a state change update **only the widget
251
+ // that uses it** instead of rebuilding the app.
252
+ const reactiveValue = createReactiveValue(() => getRam(key), key);
209
253
 
210
254
  // Return an array matching React's useState pattern: [value, setter, subscriber]
211
255
  return [reactiveValue, setState, subscribe];
@@ -242,6 +286,9 @@ export const cleanupUseState = () => {
242
286
  }
243
287
  subscribers.clear();
244
288
  widgetsByKey.clear();
289
+ imperativeKeys.clear();
290
+ // The dispatcher was unsubscribed above, so a later call must reinstall it.
291
+ ramDispatcherReady = false;
245
292
  globalRender = null;
246
293
  if (renderTimeout) clearTimeout(renderTimeout);
247
294
  };
@@ -10,7 +10,9 @@
10
10
  * // { text: 'Click', variant: 'filled', color: '#007aff' }
11
11
  */
12
12
  export const getWidgetProps = (widget) => {
13
- return { ...(widget._props || {}) };
13
+ // The factory stores props in `_originalProps`. `_props` was never written by
14
+ // anything, so this always returned `{}` — Inspector printed no props at all.
15
+ return { ...(widget._originalProps || {}) };
14
16
  };
15
17
 
16
18
  /**
@@ -20,7 +22,7 @@ export const getWidgetProps = (widget) => {
20
22
  * @returns {any} Prop value or undefined
21
23
  */
22
24
  export const getWidgetProp = (widget, propName) => {
23
- return widget._props ? widget._props[propName] : undefined;
25
+ return widget._originalProps ? widget._originalProps[propName] : undefined;
24
26
  };
25
27
 
26
28
  /**
@@ -29,7 +31,7 @@ export const getWidgetProp = (widget, propName) => {
29
31
  * @returns {string} Formatted props string
30
32
  */
31
33
  export const stringifyWidgetProps = (widget) => {
32
- return JSON.stringify(widget._props || {}, null, 2);
34
+ return JSON.stringify(widget._originalProps || {}, null, 2);
33
35
  };
34
36
 
35
37
  export default { getWidgetProps, getWidgetProp, stringifyWidgetProps };
@@ -1,5 +1,6 @@
1
1
  // core/assignProps.js
2
2
  import { setStyles } from "./tools.js";
3
+ import { resolveReactive } from "./reactiveValue.js";
3
4
 
4
5
  /**
5
6
  * Extracts the real value from a prop that may be a reactive accessor or a
@@ -14,15 +15,7 @@ import { setStyles } from "./tools.js";
14
15
  * @param {*} val - The raw prop value (may be reactive or plain).
15
16
  * @returns {*} The resolved runtime value.
16
17
  */
17
- const getValue = (val) => {
18
- if (typeof val === "function" && val._isReactive) {
19
- return val();
20
- }
21
- if (val && typeof val === "object" && val._isReactive === true) {
22
- return val._value !== undefined ? val._value : val.valueOf();
23
- }
24
- return val;
25
- };
18
+ const getValue = resolveReactive;
26
19
 
27
20
  /**
28
21
  * HTML attributes whose mere presence means "true". Writing them through
@@ -110,7 +103,11 @@ export const assignProps = (widget, props) => {
110
103
  // ============================================================
111
104
  // 2. TEXT
112
105
  // ============================================================
113
- if (props.textContent !== undefined) {
106
+ // `null` is `processProps`' sentinel for "this prop set carries no text".
107
+ // Only an explicitly present value (including "") may touch textContent:
108
+ // assigning null stringifies to "" and therefore REMOVES every child, which
109
+ // silently wiped widgets on any `update()` that did not re-pass `child`.
110
+ if (props.textContent !== undefined && props.textContent !== null) {
114
111
  const textValue = getValue(props.textContent);
115
112
  if (widget.tagName === "INPUT" || widget.tagName === "TEXTAREA") {
116
113
  if (widget.value !== textValue) widget.value = textValue;
@@ -1,6 +1,7 @@
1
1
  // core/processProps.js
2
2
  import { getPropDefinition, ATTRIBUTE_DOM_SET } from "./translateProps.js";
3
3
  import { toREM } from "./remTool.js"; // Centralized REM conversion utility
4
+ import { resolveReactive } from "./reactiveValue.js";
4
5
 
5
6
  /**
6
7
  * Processes raw props and classifies them into:
@@ -26,8 +27,12 @@ export const processProps = (props, tag = "div", widgetName = null) => {
26
27
  // Derive widget name from tag (e.g., 'div' → 'Div', 'span' → 'Span')
27
28
  const name = widgetName || tag.charAt(0).toUpperCase() + tag.slice(1);
28
29
 
29
- for (const [key, value] of Object.entries(props)) {
30
- if (value === undefined || value === null) continue;
30
+ for (const [key, rawValue] of Object.entries(props)) {
31
+ if (rawValue === undefined || rawValue === null) continue;
32
+ // Resolve the reactive-prop protocol once, here. A live value must be READ
33
+ // now so `width: count` yields a number (and gets its px/rem unit), and a
34
+ // function must never reach a CSS assignment.
35
+ const value = resolveReactive(rawValue);
31
36
 
32
37
  // `style` is deliberately NOT part of the API: FletBox uses flat, aliased
33
38
  // CSS props. Fail loudly instead of silently ignoring the object.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file reactiveValue.js
3
+ * @description
4
+ * The reactive-prop protocol shared by `useState`, the widget factory and the
5
+ * prop pipeline.
6
+ *
7
+ * A prop value may be a *reactive value*: an object or function flagged with
8
+ * `_isReactive` that reports the current value of a state key. The factory
9
+ * resolves it whenever the prop is applied, and — when the value carries a
10
+ * `_stateKey` — it also **subscribes** the widget to that key so a later change
11
+ * updates only this widget instead of re-rendering the whole app.
12
+ *
13
+ * Two shapes are supported:
14
+ *
15
+ * - **Live** (what `useState` returns): a callable that reads the store on every
16
+ * call. `valueOf`/`toString` also read live, so `${count}`, `count + 1` and
17
+ * CSS assignment all get the current value.
18
+ * - **Snapshot** (legacy): an object with a frozen `_value`. Still resolved, but
19
+ * it cannot auto-subscribe because it has no live source.
20
+ *
21
+ * @module widget-factory/reactiveValue
22
+ */
23
+
24
+ /**
25
+ * True while `resolveReactive` is reading a value on behalf of the factory.
26
+ *
27
+ * The distinction matters: a read the FACTORY performs (to fill a prop) is a
28
+ * direct binding, but a read the COMPONENT performs (`${count}`, `count > 3`,
29
+ * `count.valueOf()`) means the value also drives structure or computation, so the
30
+ * app has to re-run as well. Reconciling the two is what keeps
31
+ * `Text({ text: count })` surgical without breaking a sibling conditional.
32
+ */
33
+ let resolving = false;
34
+
35
+ /**
36
+ * Depth of the component-render pass, so reads in an event handler or a timer
37
+ * (which run outside the pass) do not mark a key as structurally read.
38
+ */
39
+ let renderPassDepth = 0;
40
+
41
+ /** Called with the state key when the component reads a reactive value. */
42
+ let onImperativeRead = null;
43
+
44
+ /**
45
+ * Registers the listener told about structural reads. The state module uses it to
46
+ * know which keys still need the app render in addition to the surgical patch.
47
+ *
48
+ * @param {(key: string) => void} listener
49
+ * @returns {void}
50
+ */
51
+ export const setImperativeReadListener = (listener) => {
52
+ onImperativeRead = listener;
53
+ };
54
+
55
+ /** Marks the start of a component-render pass. */
56
+ export const beginRenderPass = () => {
57
+ renderPassDepth++;
58
+ };
59
+
60
+ /** Marks the end of a component-render pass. */
61
+ export const endRenderPass = () => {
62
+ if (renderPassDepth > 0) renderPassDepth--;
63
+ };
64
+
65
+ /**
66
+ * True when `val` is a reactive value (live or snapshot).
67
+ *
68
+ * @param {*} val
69
+ * @returns {boolean}
70
+ */
71
+ export const isReactive = (val) => Boolean(val) && val._isReactive === true;
72
+
73
+ /**
74
+ * The state key a reactive value is bound to, or `null` when it is not bound.
75
+ *
76
+ * @param {*} val
77
+ * @returns {string|null}
78
+ */
79
+ export const reactiveKeyOf = (val) => (isReactive(val) ? val._stateKey ?? null : null);
80
+
81
+ /**
82
+ * Resolves a reactive value to its current value. Plain values pass through.
83
+ *
84
+ * A live value is invoked, which is what makes it stay current: every time the
85
+ * prop is re-applied the store is read again. The read is flagged as a factory
86
+ * read so it is not mistaken for a structural one.
87
+ *
88
+ * @param {*} val
89
+ * @returns {*}
90
+ */
91
+ export const resolveReactive = (val) => {
92
+ if (typeof val === "function" && val._isReactive) {
93
+ resolving = true;
94
+ try {
95
+ return val();
96
+ } finally {
97
+ resolving = false;
98
+ }
99
+ }
100
+ if (val && typeof val === "object" && val._isReactive === true) {
101
+ return val._value !== undefined ? val._value : val.valueOf();
102
+ }
103
+ return val;
104
+ };
105
+
106
+ /**
107
+ * Builds the live reactive value returned by `useState`.
108
+ *
109
+ * It is a **function** (so `resolveReactive` calls it and always gets the
110
+ * current value) decorated with `valueOf`/`toString` (so template literals and
111
+ * arithmetic work) and a `_value` getter (so the legacy `state._value` read
112
+ * keeps working — now live instead of frozen).
113
+ *
114
+ * Every read that the component itself performs during a render pass is reported
115
+ * through `onImperativeRead`, because such a read means the value is consumed
116
+ * structurally and the app must re-render too.
117
+ *
118
+ * @param {() => *} read - Reads the current value from the store.
119
+ * @param {string} key - The state key this value is bound to.
120
+ * @returns {Function} The live reactive value.
121
+ */
122
+ export const createReactiveValue = (read, key) => {
123
+ const live = () => {
124
+ const value = read();
125
+ if (!resolving && renderPassDepth > 0 && onImperativeRead) onImperativeRead(key);
126
+ return value;
127
+ };
128
+ live._isReactive = true;
129
+ live._stateKey = key;
130
+ live.valueOf = () => live();
131
+ live.toString = () => String(live());
132
+ Object.defineProperty(live, "_value", {
133
+ get: () => live(),
134
+ configurable: true,
135
+ });
136
+ return live;
137
+ };
138
+
139
+ export default {
140
+ isReactive,
141
+ reactiveKeyOf,
142
+ resolveReactive,
143
+ createReactiveValue,
144
+ setImperativeReadListener,
145
+ beginRenderPass,
146
+ endRenderPass,
147
+ };