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.
- 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/index.d.ts +191 -78
- package/src/navigations/AppBar.js +8 -2
- package/src/navigations/BottomNavigation.js +14 -2
- package/src/navigations/DrawerItem.js +16 -3
- package/src/navigations/Scaffold.js +8 -1
- package/src/navigations/Tabs.js +34 -6
- 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
|
@@ -277,9 +277,15 @@ export const AppBar = (props) => {
|
|
|
277
277
|
lastScrollY = currentScrollY;
|
|
278
278
|
};
|
|
279
279
|
window.addEventListener("scroll", handleScroll);
|
|
280
|
-
//
|
|
281
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
|
package/src/navigations/Tabs.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/src/tools/dimensions.js
CHANGED
|
@@ -16,8 +16,14 @@
|
|
|
16
16
|
* // Call remove() to stop listening.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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.
|
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
|
+
};
|