flet-box 1.0.2 → 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 (48) hide show
  1. package/bin/ota/loader.js +6 -1
  2. package/bin/utils/templates.js +6 -2
  3. package/package.json +3 -2
  4. package/src/animations/AnimatedBox.js +32 -1
  5. package/src/animations/AnimatedText.js +7 -3
  6. package/src/animations/MatrixRain.js +37 -12
  7. package/src/animations/ParallaxBox.js +16 -3
  8. package/src/animations/animate.js +22 -3
  9. package/src/core/runApp.js +13 -1
  10. package/src/create-index.sh +2 -2
  11. package/src/index.d.ts +193 -79
  12. package/src/index.js +1 -0
  13. package/src/navigations/AppBar.js +17 -4
  14. package/src/navigations/BottomNavigation.js +14 -2
  15. package/src/navigations/DrawerItem.js +16 -3
  16. package/src/navigations/Router.js +125 -6
  17. package/src/navigations/Scaffold.js +13 -3
  18. package/src/navigations/Tabs.js +34 -6
  19. package/src/navigations/index.js +2 -0
  20. package/src/tools/dimensions.js +11 -3
  21. package/src/tools/useState.js +115 -68
  22. package/src/utils/getWidgetProps.js +5 -3
  23. package/src/widget-factory/assignProps.js +7 -10
  24. package/src/widget-factory/processProps.js +7 -2
  25. package/src/widget-factory/reactiveValue.js +147 -0
  26. package/src/widget-factory/warnings.js +15 -0
  27. package/src/widget-factory/widgetFactory.js +27 -0
  28. package/src/widgets/Accordion.js +6 -5
  29. package/src/widgets/Audio.js +30 -8
  30. package/src/widgets/BottomSheet.js +24 -1
  31. package/src/widgets/Chart.js +4 -1
  32. package/src/widgets/Checkbox.js +7 -1
  33. package/src/widgets/CircularChart.js +4 -1
  34. package/src/widgets/CodeViewer.js +26 -8
  35. package/src/widgets/DataTable.js +11 -2
  36. package/src/widgets/Dropdown.js +5 -1
  37. package/src/widgets/Image.js +8 -1
  38. package/src/widgets/Input.js +27 -6
  39. package/src/widgets/Inspector.js +16 -10
  40. package/src/widgets/ListView.js +7 -1
  41. package/src/widgets/Markdown.js +53 -8
  42. package/src/widgets/Modal.js +24 -1
  43. package/src/widgets/QRCode.js +9 -2
  44. package/src/widgets/Radio.js +5 -1
  45. package/src/widgets/Slider.js +3 -0
  46. package/src/widgets/SnackBar.js +20 -2
  47. package/src/widgets/Switch.js +5 -1
  48. package/src/widgets/TreeView.js +3 -1
@@ -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
+ };
@@ -7,6 +7,14 @@ const IS_DEV = typeof FLETBOX_DEV !== 'undefined' ? FLETBOX_DEV : true;
7
7
  const suggestionCache = new Map();
8
8
  let propNames = [];
9
9
 
10
+ // Props already reported. Without this, every prop *application* re-printed the
11
+ // whole block — a captured stack trace plus ~8 styled console calls — so a widget
12
+ // carrying a single unknown prop paid ~137 µs on every `update()`, roughly 400×
13
+ // the cost of a known prop. Because `update()` re-applies every original prop, one
14
+ // unknown prop slowed that widget for its whole lifetime.
15
+ const warnedProps = new Set();
16
+ const warnedDeprecated = new Set();
17
+
10
18
  export function setPropNames(names) {
11
19
  propNames = names;
12
20
  try { if (typeof window !== 'undefined') window.__FLETBOX_PROP_NAMES = names; } catch(e){}
@@ -216,6 +224,9 @@ function printDocs(prop) {
216
224
 
217
225
  export function warnUnknownProp(propName, context = {}) {
218
226
  if (!IS_DEV) return;
227
+ // Report each unknown prop once, not once per application.
228
+ if (warnedProps.has(propName)) return;
229
+ warnedProps.add(propName);
219
230
 
220
231
  const suggestions = getSuggestions(propName);
221
232
  const location = getCallLocation();
@@ -231,6 +242,10 @@ export function warnUnknownProp(propName, context = {}) {
231
242
 
232
243
  export function warnDeprecatedProp(propName, replacement = null, context = {}) {
233
244
  if (!IS_DEV) return;
245
+ // Same reasoning as `warnUnknownProp`: one report per prop/replacement pair.
246
+ const deprecatedKey = `${propName}\u0000${replacement || ""}`;
247
+ if (warnedDeprecated.has(deprecatedKey)) return;
248
+ warnedDeprecated.add(deprecatedKey);
234
249
 
235
250
  const location = getCallLocation();
236
251
 
@@ -8,6 +8,29 @@ import { processProps } from "./processProps.js";
8
8
  import { assignProps } from "./assignProps.js";
9
9
  import { stackPosition } from "./stackPosition.js";
10
10
  import { addChildren, removePropChildren } from "./addChildren.js";
11
+ import { reactiveKeyOf } from "./reactiveValue.js";
12
+ import { registerWidget } from "../tools/useState.js";
13
+
14
+ /**
15
+ * Auto-binds reactive props to their state key.
16
+ *
17
+ * A prop whose value is a live reactive (what `useState` returns) carries the key
18
+ * it came from, so the factory can subscribe this widget to that key. A later
19
+ * change then updates **only this widget** instead of rebuilding the whole app —
20
+ * which is what makes the plain
21
+ * `const [color, setColor] = useState("color", "red"); Widget({ bgColor: color })`
22
+ * syntax surgical.
23
+ *
24
+ * @param {HTMLElement} widget - The widget being built or updated.
25
+ * @param {Object} props - The props to scan.
26
+ * @returns {void}
27
+ */
28
+ const bindReactiveProps = (widget, props) => {
29
+ for (const [prop, value] of Object.entries(props || {})) {
30
+ const key = reactiveKeyOf(value);
31
+ if (key) registerWidget(key, widget, prop);
32
+ }
33
+ };
11
34
 
12
35
  /**
13
36
  * Main widget factory. Creates a fully functional DOM widget with reactivity,
@@ -96,6 +119,7 @@ export function WidgetFactory(tag, customProps = {}) {
96
119
  // ============================================================
97
120
  const initialProps = processProps(finalProps, finalTag, widgetName);
98
121
  assignProps(widget, initialProps);
122
+ bindReactiveProps(widget, finalProps);
99
123
  stackPosition(widget, finalProps);
100
124
 
101
125
  if (finalProps.child !== undefined) {
@@ -160,6 +184,9 @@ export function WidgetFactory(tag, customProps = {}) {
160
184
 
161
185
  const processed = processProps(merged, finalTag, widgetName);
162
186
  assignProps(widget, processed);
187
+ // Only the incoming keys can introduce a new binding; the ones present at
188
+ // construction were registered then.
189
+ bindReactiveProps(widget, normalized);
163
190
  stackPosition(widget, merged);
164
191
 
165
192
  if (hasChild || hasChildren) {
@@ -156,11 +156,12 @@ export const Accordion = (props) => {
156
156
  titleElement.style.fontSize = `${currentTitleSize}px`;
157
157
  titleElement.style.fontWeight = currentTitleWeight;
158
158
 
159
- // Swap icon based on current expanded state
160
- iconElement.setAttribute(
161
- "name",
162
- isExpanded ? currentIconExpanded : currentIconCollapsed,
163
- );
159
+ // Swap icon based on current expanded state. `Icon` renders its glyph as
160
+ // TEXT (a material-icons ligature), so setting a `name` attribute changed
161
+ // nothing and the chevron stayed on `chevron_right` forever.
162
+ iconElement.textContent = isExpanded
163
+ ? currentIconExpanded
164
+ : currentIconCollapsed;
164
165
  iconElement.style.color = currentIconColor;
165
166
  iconElement.style.fontSize = `${currentIconSize}px`;
166
167
 
@@ -74,6 +74,9 @@ export const Audio = (props) => {
74
74
  let duration = 0;
75
75
  let durationPoll = null;
76
76
  let audioContextFallbackAttempted = false;
77
+ // Set on unmount so deferred work (the duration fallback, in-flight fetches)
78
+ // never touches a discarded widget.
79
+ let disposed = false;
77
80
 
78
81
  /**
79
82
  * Recalculates the current playback position and fires the progress callbacks.
@@ -139,19 +142,25 @@ export const Audio = (props) => {
139
142
  * cannot determine duration without fully downloading the file.
140
143
  */
141
144
  const fallbackGetDuration = async () => {
142
- if (audioContextFallbackAttempted) return;
145
+ if (audioContextFallbackAttempted || disposed) return;
143
146
  audioContextFallbackAttempted = true;
144
147
 
145
148
  try {
146
149
  const response = await fetch(src);
147
150
  const arrayBuffer = await response.arrayBuffer();
151
+ if (disposed) return;
148
152
  const audioContext = new (
149
153
  window.AudioContext || window.webkitAudioContext
150
154
  )();
151
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
152
- const durationSeconds = audioBuffer.duration;
153
- setDuration(durationSeconds);
154
- audioContext.close();
155
+ // `close()` must run even when decoding fails: AudioContexts are a capped
156
+ // resource (~6 per page in Chromium) and leaking them makes every later
157
+ // context fail to start, which breaks the fallback for good.
158
+ try {
159
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
160
+ setDuration(audioBuffer.duration);
161
+ } finally {
162
+ audioContext.close();
163
+ }
155
164
  } catch (err) {
156
165
  console.warn("[Audio] Fallback duration fetch failed:", err);
157
166
  }
@@ -178,16 +187,29 @@ export const Audio = (props) => {
178
187
  audio.addEventListener("error", (e) => console.warn("[Audio] Error:", e));
179
188
 
180
189
  // Kick off duration detection immediately; if unavailable after 2 s, use fetch fallback
190
+ let fallbackTimer = null;
181
191
  if (!tryGetDurationFromAudio()) {
182
192
  pollDuration();
183
- setTimeout(() => {
193
+ fallbackTimer = setTimeout(() => {
194
+ fallbackTimer = null;
184
195
  if (!duration) fallbackGetDuration();
185
196
  }, 2000);
186
197
  }
187
198
 
188
- // Cancel the duration polling interval when the widget unmounts
199
+ // Release everything the widget owns on unmount. The 2 s fallback timer was
200
+ // never stored, so after teardown it still fetched the whole file and spun up
201
+ // an AudioContext (and called onLoad) for a widget that no longer exists — and
202
+ // a tree rebuild does that on every state change.
189
203
  audio.onUnmount(() => {
190
- if (durationPoll) clearInterval(durationPoll);
204
+ disposed = true;
205
+ if (durationPoll) {
206
+ clearInterval(durationPoll);
207
+ durationPoll = null;
208
+ }
209
+ if (fallbackTimer) {
210
+ clearTimeout(fallbackTimer);
211
+ fallbackTimer = null;
212
+ }
191
213
  });
192
214
 
193
215
  // ========== PUBLIC PLAYBACK API ==========