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.
- package/bin/ota/loader.js +6 -1
- package/bin/utils/templates.js +6 -2
- package/package.json +3 -2
- package/src/animations/AnimatedBox.js +32 -1
- package/src/animations/AnimatedText.js +7 -3
- package/src/animations/MatrixRain.js +37 -12
- package/src/animations/ParallaxBox.js +16 -3
- package/src/animations/animate.js +22 -3
- package/src/core/runApp.js +13 -1
- package/src/create-index.sh +2 -2
- package/src/index.d.ts +193 -79
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +17 -4
- package/src/navigations/BottomNavigation.js +14 -2
- package/src/navigations/DrawerItem.js +16 -3
- package/src/navigations/Router.js +125 -6
- package/src/navigations/Scaffold.js +13 -3
- package/src/navigations/Tabs.js +34 -6
- package/src/navigations/index.js +2 -0
- package/src/tools/dimensions.js +11 -3
- package/src/tools/useState.js +115 -68
- package/src/utils/getWidgetProps.js +5 -3
- package/src/widget-factory/assignProps.js +7 -10
- package/src/widget-factory/processProps.js +7 -2
- package/src/widget-factory/reactiveValue.js +147 -0
- package/src/widget-factory/warnings.js +15 -0
- package/src/widget-factory/widgetFactory.js +27 -0
- package/src/widgets/Accordion.js +6 -5
- package/src/widgets/Audio.js +30 -8
- package/src/widgets/BottomSheet.js +24 -1
- package/src/widgets/Chart.js +4 -1
- package/src/widgets/Checkbox.js +7 -1
- package/src/widgets/CircularChart.js +4 -1
- package/src/widgets/CodeViewer.js +26 -8
- package/src/widgets/DataTable.js +11 -2
- package/src/widgets/Dropdown.js +5 -1
- package/src/widgets/Image.js +8 -1
- package/src/widgets/Input.js +27 -6
- package/src/widgets/Inspector.js +16 -10
- package/src/widgets/ListView.js +7 -1
- package/src/widgets/Markdown.js +53 -8
- package/src/widgets/Modal.js +24 -1
- package/src/widgets/QRCode.js +9 -2
- package/src/widgets/Radio.js +5 -1
- package/src/widgets/Slider.js +3 -0
- package/src/widgets/SnackBar.js +20 -2
- package/src/widgets/Switch.js +5 -1
- package/src/widgets/TreeView.js +3 -1
package/src/tools/useState.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
119
|
+
// Idempotent: rebuilding the same widget position must not stack duplicates.
|
|
120
|
+
const already = propNames.has(propName);
|
|
121
|
+
propNames.add(propName);
|
|
52
122
|
|
|
53
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
84
|
-
if (
|
|
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
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
// 🔥
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
30
|
-
if (
|
|
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) {
|
package/src/widgets/Accordion.js
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
package/src/widgets/Audio.js
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 ==========
|