flet-box 1.0.3 → 1.0.5

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 (172) hide show
  1. package/README.md +10 -0
  2. package/bin/cli.js +15 -0
  3. package/bin/commands/checkApi.js +761 -0
  4. package/bin/commands/create.js +77 -5
  5. package/bin/utils/dtsProps.js +191 -0
  6. package/bin/utils/moduleExports.js +165 -0
  7. package/bin/utils/neutralise.js +64 -0
  8. package/bin/utils/templates.js +366 -27
  9. package/docs/CONTRIBUTING.md +156 -0
  10. package/docs/README.md +68 -0
  11. package/docs/arquitectura.md +164 -0
  12. package/docs/cli/README.md +542 -0
  13. package/docs/guides/README.md +33 -0
  14. package/docs/guides/app-templates.md +197 -0
  15. package/docs/guides/compatibility.md +95 -0
  16. package/docs/guides/embedding.md +284 -0
  17. package/docs/guides/first-app.md +208 -0
  18. package/docs/guides/frontend-services.md +171 -0
  19. package/docs/guides/minification-and-protection.md +260 -0
  20. package/docs/guides/mobile-and-platforms.md +439 -0
  21. package/docs/guides/online-updates.md +196 -0
  22. package/docs/guides/router.md +282 -0
  23. package/docs/guides/state.md +283 -0
  24. package/docs/guides/syntax-philosophy.md +150 -0
  25. package/docs/guides/utilities.md +241 -0
  26. package/docs/guides/widget-structure.md +163 -0
  27. package/docs/server/README.md +68 -0
  28. package/docs/server/START_HERE.md +121 -0
  29. package/docs/server/api.md +225 -0
  30. package/docs/server/data-services.md +300 -0
  31. package/docs/server/deployment.md +113 -0
  32. package/docs/server/security.md +196 -0
  33. package/docs/services/README.md +79 -0
  34. package/docs/services/RamStore.md +84 -0
  35. package/docs/services/Session.md +84 -0
  36. package/docs/services/Storage.md +88 -0
  37. package/docs/services/http.md +86 -0
  38. package/docs/tools/README.md +138 -0
  39. package/docs/tools/animation.md +87 -0
  40. package/docs/tools/async-ids-log.md +80 -0
  41. package/docs/tools/device.md +132 -0
  42. package/docs/tools/introspection.md +67 -0
  43. package/docs/tools/lists.md +96 -0
  44. package/docs/tools/markdown.md +79 -0
  45. package/docs/tools/reactivity.md +288 -0
  46. package/docs/tools/state.md +103 -0
  47. package/docs/tools/styling.md +92 -0
  48. package/docs/tools/text-and-time.md +89 -0
  49. package/docs/tools/theme.md +102 -0
  50. package/docs/tools/validation.md +75 -0
  51. package/docs/tools/visual-effects.md +84 -0
  52. package/docs/widget/Accordion.md +144 -0
  53. package/docs/widget/AdMob.md +144 -0
  54. package/docs/widget/AdSense.md +133 -0
  55. package/docs/widget/AdaptiveScaffold.md +105 -0
  56. package/docs/widget/AlertDialog.md +103 -0
  57. package/docs/widget/AnimatedBox.md +130 -0
  58. package/docs/widget/AnimatedText.md +123 -0
  59. package/docs/widget/AppBar.md +118 -0
  60. package/docs/widget/Audio.md +135 -0
  61. package/docs/widget/Avatar.md +119 -0
  62. package/docs/widget/Badge.md +128 -0
  63. package/docs/widget/BottomNavigation.md +116 -0
  64. package/docs/widget/BottomSheet.md +125 -0
  65. package/docs/widget/Button.md +137 -0
  66. package/docs/widget/COMMON_PROPS.md +150 -0
  67. package/docs/widget/Card.md +125 -0
  68. package/docs/widget/Carousel.md +151 -0
  69. package/docs/widget/Chart.md +151 -0
  70. package/docs/widget/Checkbox.md +118 -0
  71. package/docs/widget/Chip.md +126 -0
  72. package/docs/widget/CircularBar.md +168 -0
  73. package/docs/widget/CircularChart.md +164 -0
  74. package/docs/widget/CodeViewer.md +130 -0
  75. package/docs/widget/CollapsibleSideBar.md +112 -0
  76. package/docs/widget/Column.md +129 -0
  77. package/docs/widget/Container.md +136 -0
  78. package/docs/widget/DataTable.md +150 -0
  79. package/docs/widget/Divider.md +109 -0
  80. package/docs/widget/DraggBox.md +129 -0
  81. package/docs/widget/Drawer.md +135 -0
  82. package/docs/widget/DrawerItem.md +98 -0
  83. package/docs/widget/Dropdown.md +139 -0
  84. package/docs/widget/DroppBox.md +152 -0
  85. package/docs/widget/FloatingActionButton.md +120 -0
  86. package/docs/widget/GridView.md +140 -0
  87. package/docs/widget/Icon.md +117 -0
  88. package/docs/widget/Image.md +125 -0
  89. package/docs/widget/Input.md +151 -0
  90. package/docs/widget/Inspector.md +92 -0
  91. package/docs/widget/InstallButton.md +128 -0
  92. package/docs/widget/ListTile.md +132 -0
  93. package/docs/widget/ListView.md +138 -0
  94. package/docs/widget/Markdown.md +175 -0
  95. package/docs/widget/MatrixRain.md +122 -0
  96. package/docs/widget/Modal.md +129 -0
  97. package/docs/widget/Pagination.md +135 -0
  98. package/docs/widget/ParallaxBox.md +128 -0
  99. package/docs/widget/ProgressBar.md +130 -0
  100. package/docs/widget/QRCode.md +117 -0
  101. package/docs/widget/README.md +188 -0
  102. package/docs/widget/Radio.md +123 -0
  103. package/docs/widget/Rating.md +131 -0
  104. package/docs/widget/Row.md +131 -0
  105. package/docs/widget/START_HERE.md +146 -0
  106. package/docs/widget/Scaffold.md +152 -0
  107. package/docs/widget/Skeleton.md +130 -0
  108. package/docs/widget/Slider.md +146 -0
  109. package/docs/widget/SnackBar.md +102 -0
  110. package/docs/widget/Stack.md +128 -0
  111. package/docs/widget/Stepper.md +145 -0
  112. package/docs/widget/Switch.md +118 -0
  113. package/docs/widget/Tabs.md +124 -0
  114. package/docs/widget/Text.md +133 -0
  115. package/docs/widget/Tooltip.md +145 -0
  116. package/docs/widget/TreeView.md +176 -0
  117. package/docs/widget/Video.md +127 -0
  118. package/docs/widget/_template.md +61 -0
  119. package/package.json +4 -2
  120. package/src/animations/AnimatedBox.js +32 -1
  121. package/src/animations/AnimatedText.js +7 -3
  122. package/src/animations/MatrixRain.js +37 -12
  123. package/src/animations/ParallaxBox.js +16 -3
  124. package/src/animations/animate.js +187 -58
  125. package/src/animations/index.js +1 -0
  126. package/src/core/runApp.js +13 -1
  127. package/src/create-index.sh +1 -1
  128. package/src/index.d.ts +227 -91
  129. package/src/index.js +1 -0
  130. package/src/navigations/AdaptiveScaffold.js +11 -2
  131. package/src/navigations/AppBar.js +8 -2
  132. package/src/navigations/BottomNavigation.js +24 -5
  133. package/src/navigations/DrawerItem.js +16 -3
  134. package/src/navigations/Scaffold.js +8 -1
  135. package/src/navigations/Tabs.js +40 -8
  136. package/src/tools/dimensions.js +11 -3
  137. package/src/tools/useState.js +139 -101
  138. package/src/utils/getWidgetProps.js +5 -3
  139. package/src/widget-factory/assignProps.js +30 -11
  140. package/src/widget-factory/processProps.js +17 -5
  141. package/src/widget-factory/reactiveValue.js +172 -0
  142. package/src/widget-factory/translateProps.js +777 -901
  143. package/src/widget-factory/warnings.js +15 -0
  144. package/src/widget-factory/widgetFactory.js +27 -0
  145. package/src/widgets/Accordion.js +6 -5
  146. package/src/widgets/AlertDialog.js +7 -5
  147. package/src/widgets/Audio.js +30 -8
  148. package/src/widgets/BottomSheet.js +24 -1
  149. package/src/widgets/Chart.js +4 -1
  150. package/src/widgets/Checkbox.js +16 -8
  151. package/src/widgets/CircularBar.js +12 -1
  152. package/src/widgets/CircularChart.js +4 -1
  153. package/src/widgets/CodeViewer.js +26 -8
  154. package/src/widgets/DataTable.js +11 -2
  155. package/src/widgets/Dropdown.js +5 -1
  156. package/src/widgets/FloatingActionButton.js +4 -7
  157. package/src/widgets/Image.js +7 -2
  158. package/src/widgets/Input.js +119 -6
  159. package/src/widgets/Inspector.js +16 -10
  160. package/src/widgets/ListView.js +80 -30
  161. package/src/widgets/Markdown.js +53 -8
  162. package/src/widgets/Modal.js +24 -1
  163. package/src/widgets/Pagination.js +6 -3
  164. package/src/widgets/ProgressBar.js +16 -13
  165. package/src/widgets/QRCode.js +9 -2
  166. package/src/widgets/Radio.js +11 -8
  167. package/src/widgets/Slider.js +26 -4
  168. package/src/widgets/SnackBar.js +20 -2
  169. package/src/widgets/Stack.js +36 -3
  170. package/src/widgets/Stepper.js +4 -3
  171. package/src/widgets/Switch.js +8 -5
  172. package/src/widgets/TreeView.js +3 -2
package/src/index.js CHANGED
@@ -280,6 +280,7 @@ export {
280
280
  ParallaxBox,
281
281
  animate,
282
282
  animateAsync,
283
+ stopAnimation,
283
284
  fadeIn,
284
285
  fadeOut,
285
286
  pulse,
@@ -81,7 +81,10 @@ export const AdaptiveScaffold = (props) => {
81
81
  // button to drawerInstance.open().
82
82
  drawerInstance = Drawer({
83
83
  header: null,
84
- body: [content],
84
+ // `body` is an array of widgets, and `leftNavBar` may itself be one, so
85
+ // the content is flattened here. A nested array would reach `children`
86
+ // as a single item and break the append.
87
+ body: Array.isArray(content) ? content : [content],
85
88
  footer: null,
86
89
  position: "left",
87
90
  width: leftNavBarWidth,
@@ -125,6 +128,12 @@ export const AdaptiveScaffold = (props) => {
125
128
  widthExpanded: rightNavBarWidth,
126
129
  widthCollapsed: 50,
127
130
  bgColor: colors.surface,
131
+ // FIX: CollapsibleSideBar aplica `borderRight` por defecto (default
132
+ // `1px solid ${colors.border}`), así que un sidebar derecho acababa
133
+ // con doble borde: `borderRight` (heredado del default) + `borderLeft`
134
+ // (el que pedimos aquí). Forzamos `borderRight: "none"` para que solo
135
+ // tenga el borde izquierdo (el que da al contenido central).
136
+ borderRight: "none",
128
137
  borderLeft: `1px solid ${colors.border}`,
129
138
  children: content,
130
139
  });
@@ -146,4 +155,4 @@ export const AdaptiveScaffold = (props) => {
146
155
  });
147
156
  };
148
157
 
149
- export default AdaptiveScaffold;
158
+ export default AdaptiveScaffold;
@@ -277,9 +277,15 @@ export const AppBar = (props) => {
277
277
  lastScrollY = currentScrollY;
278
278
  };
279
279
  window.addEventListener("scroll", handleScroll);
280
- // Store the remover so _cleanup can deregister the listener.
281
- appBar._cleanupScroll = () =>
280
+ // Register the remover through BOTH teardown paths. `_cleanup` is only called
281
+ // by runApp's teardownTree / Scaffold, while a plain DOM disconnect goes
282
+ // through the lifecycle observer, which never calls `_cleanup` — so without
283
+ // `onUnmount` this listener (and the detached bar) stayed alive forever.
284
+ // `removeEventListener` is idempotent, so running both is harmless.
285
+ const removeScroll = () =>
282
286
  window.removeEventListener("scroll", handleScroll);
287
+ appBar._cleanupScroll = removeScroll;
288
+ appBar.onUnmount(removeScroll);
283
289
  }
284
290
 
285
291
  // ========== PUBLIC API ==========
@@ -149,6 +149,7 @@ export const BottomNavigation = (props) => {
149
149
  if (!useRouter) return;
150
150
 
151
151
  const currentPath = getCurrentPath();
152
+ const previousIndex = activeIndex;
152
153
 
153
154
  buttons.forEach((btnData, idx) => {
154
155
  const isSelected = btnData.route === currentPath;
@@ -163,7 +164,10 @@ export const BottomNavigation = (props) => {
163
164
  if (isSelected) activeIndex = idx;
164
165
  });
165
166
 
166
- if (onTabChange) onTabChange(activeIndex);
167
+ // Fire only when the route actually moved the selection. A consumer that
168
+ // navigates from `onTabChange` (the normal case) would otherwise recurse:
169
+ // route change -> callback -> goTo -> route change, until the stack blows.
170
+ if (activeIndex !== previousIndex && onTabChange) onTabChange(activeIndex);
167
171
  };
168
172
 
169
173
  // ========== BUILD TAB BUTTONS ==========
@@ -214,8 +218,11 @@ export const BottomNavigation = (props) => {
214
218
 
215
219
  let labelWidget = null;
216
220
  if (showLabels && item.label) {
221
+ // FIX: `value` es una prop `special` que `applySpecial` solo maneja
222
+ // en <input>/<textarea>. En un <span> no hace nada → el label quedaba
223
+ // vacío. La prop correcta para contenido de texto es `text`.
217
224
  labelWidget = Text({
218
- value: item.label,
225
+ text: item.label,
219
226
  size: 11,
220
227
  color: isActive ? selectedColor : unselectedColor,
221
228
  fontWeight: isActive ? "500" : "400",
@@ -285,14 +292,26 @@ export const BottomNavigation = (props) => {
285
292
  return getCurrentPath();
286
293
  };
287
294
 
288
- // Cleanup: unsubscribe from the router when the bar is removed from the DOM.
295
+ // Cleanup: release the router subscription on EITHER teardown path. `_cleanup`
296
+ // is only run by runApp's teardownTree / Scaffold; the lifecycle observer that
297
+ // fires on a plain DOM disconnect never calls it, so without `onUnmount` the
298
+ // router kept invoking `updateColorsFromRouter()` (and `onTabChange`) on a
299
+ // detached bar forever. The disposer is idempotent because both paths can run.
300
+ let unsubscribed = false;
301
+ const dispose = () => {
302
+ if (unsubscribed) return;
303
+ unsubscribed = true;
304
+ if (unsubscribe) unsubscribe();
305
+ };
306
+ container.onUnmount(dispose);
307
+
289
308
  const originalCleanup = container._cleanup;
290
309
  container._cleanup = () => {
291
- if (unsubscribe) unsubscribe();
310
+ dispose();
292
311
  if (originalCleanup) originalCleanup();
293
312
  };
294
313
 
295
314
  return container;
296
315
  };
297
316
 
298
- export default BottomNavigation;
317
+ export default BottomNavigation;
@@ -246,11 +246,24 @@ export const DrawerItem = (props) => {
246
246
  // Apply the initial visual state now that DOM refs are set.
247
247
  updateUI();
248
248
 
249
- // Cleanup: unsubscribe from router and selection events to prevent memory leaks.
250
- const originalCleanup = container._cleanup;
251
- container._cleanup = () => {
249
+ // Cleanup: release the router subscription and the selection listener on EITHER
250
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; the
251
+ // lifecycle observer that fires on a plain DOM disconnect never calls it, so
252
+ // without `onUnmount` every removed item kept a live router callback (retaining
253
+ // its DOM refs) plus a listener on the shared selection EventTarget. The
254
+ // disposer is idempotent because both paths can run.
255
+ let disposed = false;
256
+ const dispose = () => {
257
+ if (disposed) return;
258
+ disposed = true;
252
259
  if (unsubscribeRouter) unsubscribeRouter();
253
260
  if (unsubscribeGlobal) unsubscribeGlobal();
261
+ };
262
+ container.onUnmount(dispose);
263
+
264
+ const originalCleanup = container._cleanup;
265
+ container._cleanup = () => {
266
+ dispose();
254
267
  if (originalCleanup) originalCleanup();
255
268
  };
256
269
 
@@ -494,8 +494,15 @@ export const Scaffold = (props) => {
494
494
  clearContainer(fabContainer);
495
495
  clearContainer(leftNavBarContainer);
496
496
  clearContainer(rightNavBarContainer);
497
- if (container._drawer?.parentNode)
497
+ // `destroy()` releases the viewport-resize listener, the Escape key handler
498
+ // and the pending close timer, then removes the node itself. Removing only
499
+ // the node (the previous behaviour) left all three behind, so every rebuilt
500
+ // scaffold leaked one more listener and one more body child.
501
+ if (typeof container._drawer?.destroy === "function") {
502
+ container._drawer.destroy();
503
+ } else if (container._drawer?.parentNode) {
498
504
  container._drawer.parentNode.removeChild(container._drawer);
505
+ }
499
506
  if (originalCleanup) originalCleanup();
500
507
  };
501
508
 
@@ -67,7 +67,11 @@ export const Tabs = (props) => {
67
67
  size = "medium",
68
68
  color = colors.primary,
69
69
  textColor = colors.text,
70
- activeTextColor = colors.white,
70
+ // FIX: `colors.white` no existe en themes.js → caía a `undefined`
71
+ // y el tab activo no se distinguía visualmente. Uso `colors.background`
72
+ // (que es "#ffffff" en el tema claro y "#0f172a" en el oscuro, que es
73
+ // lo correcto: contraste sobre el fondo del tab activo).
74
+ activeTextColor = colors.background,
71
75
  bgColor = colors.gray100,
72
76
  buttonColor = colors.secondary,
73
77
  alignment = "left",
@@ -318,6 +322,11 @@ export const Tabs = (props) => {
318
322
  updateContent();
319
323
  };
320
324
 
325
+ // Mount the initially active pane during construction. Without this the pane
326
+ // area stayed empty until the user clicked a *different* tab, even though the
327
+ // first tab already rendered as `aria-selected="true"`.
328
+ updateContent();
329
+
321
330
  // Defer the initial slider position calculation by one frame so the DOM
322
331
  // has been laid out and getBoundingClientRect() returns real values.
323
332
  let initialPositionTimer = setTimeout(() => {
@@ -341,18 +350,41 @@ export const Tabs = (props) => {
341
350
 
342
351
  // Also observe the tab bar element itself so the thumb repositions when
343
352
  // the container resizes independently of the window (e.g. sidebar toggle).
344
- if (typeof ResizeObserver !== "undefined") {
353
+ // Only the slider variant has a thumb, so skip it for the other variants.
354
+ if (variant === "slider" && typeof ResizeObserver !== "undefined") {
345
355
  resizeObserver = new ResizeObserver(() => updateSliderPosition());
346
356
  if (tabsWrapperRef) resizeObserver.observe(tabsWrapperRef);
347
357
  }
348
358
 
349
- // Cleanup: cancel timers and remove listeners when the component is unmounted.
359
+ // Cleanup: release the timers, the window listener and the observer on EITHER
360
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; a
361
+ // plain DOM disconnect goes through the lifecycle observer, which never calls
362
+ // it — so without `onUnmount` the resize listener stayed registered and the
363
+ // ResizeObserver kept observing (and retaining) a detached tab bar. The
364
+ // disposer is idempotent because both paths can run.
365
+ let disposed = false;
366
+ const dispose = () => {
367
+ if (disposed) return;
368
+ disposed = true;
369
+ if (initialPositionTimer) {
370
+ clearTimeout(initialPositionTimer);
371
+ initialPositionTimer = null;
372
+ }
373
+ if (resizeTimeout) {
374
+ clearTimeout(resizeTimeout);
375
+ resizeTimeout = null;
376
+ }
377
+ window.removeEventListener("resize", handleResize);
378
+ if (resizeObserver) {
379
+ resizeObserver.disconnect();
380
+ resizeObserver = null;
381
+ }
382
+ };
383
+ container.onUnmount(dispose);
384
+
350
385
  const originalCleanup = container._cleanup;
351
386
  container._cleanup = () => {
352
- if (initialPositionTimer) clearTimeout(initialPositionTimer);
353
- if (resizeTimeout) clearTimeout(resizeTimeout);
354
- window.removeEventListener("resize", handleResize);
355
- if (resizeObserver) resizeObserver.disconnect();
387
+ dispose();
356
388
  if (originalCleanup) originalCleanup();
357
389
  };
358
390
 
@@ -381,4 +413,4 @@ export const Tabs = (props) => {
381
413
  return container;
382
414
  };
383
415
 
384
- export default Tabs;
416
+ export default Tabs;
@@ -16,8 +16,14 @@
16
16
  * // Call remove() to stop listening.
17
17
  */
18
18
 
19
- let windowWidth = window.innerWidth;
20
- let windowHeight = window.innerHeight;
19
+ // SSR / Node guard: this module must stay importable where `window` does not
20
+ // exist (SSR, test runners, build tooling). Reading `window.innerWidth` at
21
+ // module scope used to throw a ReferenceError on import. Same convention as
22
+ // `src/core/pwa.js`.
23
+ const hasWindow = typeof window !== "undefined";
24
+
25
+ let windowWidth = hasWindow ? window.innerWidth : 0;
26
+ let windowHeight = hasWindow ? window.innerHeight : 0;
21
27
 
22
28
  // Live named exports — is-module live bindings: importers of { width, height }
23
29
  // always read the current value, kept in sync on resize.
@@ -45,7 +51,9 @@ const handleResize = () => {
45
51
  });
46
52
  };
47
53
 
48
- window.addEventListener("resize", handleResize);
54
+ // Only subscribe inside a browser; in Node the listener list stays inert and
55
+ // `addListener` / `removeListener` keep working without throwing.
56
+ if (hasWindow) window.addEventListener("resize", handleResize);
49
57
 
50
58
  /**
51
59
  * Dimensions object — provides getter properties and listener management.
@@ -1,5 +1,10 @@
1
1
  // src/tools/useState.js
2
2
  import { saveRam, getRam, subscribeRam } from "../services/RamStore.js";
3
+ import {
4
+ createReactiveValue,
5
+ isStructuralRead,
6
+ resetStructuralReads,
7
+ } from "../widget-factory/reactiveValue.js";
3
8
 
4
9
  // Store callbacks for each state key
5
10
  const subscribers = new Map();
@@ -35,6 +40,56 @@ const triggerRender = () => {
35
40
  }, 0);
36
41
  };
37
42
 
43
+ let ramDispatcherReady = false;
44
+
45
+ /**
46
+ * Installs **one** RamStore subscriber that dispatches to the widgets and
47
+ * callbacks registered for the key that actually changed.
48
+ *
49
+ * RamStore walks a flat subscriber array on every `saveRam`, so registering one
50
+ * subscriber per key made a single state change cost O(number of keys) — with
51
+ * every callback immediately rejecting the key. One dispatcher makes the lookup
52
+ * O(1), and the work proportional to what the key really has bound.
53
+ *
54
+ * @returns {void}
55
+ */
56
+ const ensureRamDispatcher = () => {
57
+ if (ramDispatcherReady) return;
58
+ ramDispatcherReady = true;
59
+
60
+ const unsubscribe = subscribeRam((changedKey, newValue, oldValue) => {
61
+ const callbacks = subscribers.get(changedKey);
62
+ // A key nobody ever passed to `useState` has no listeners: ignore it.
63
+ if (!callbacks) return;
64
+
65
+ // 1. Surgical update: patch only the widgets bound to this key.
66
+ const bound = widgetsByKey.get(changedKey);
67
+ let handled = 0;
68
+ if (bound) {
69
+ bound.forEach((propNames, widget) => {
70
+ if (!widget || typeof widget.update !== "function") return;
71
+ propNames.forEach((propName) => {
72
+ widget.update({ [propName]: newValue });
73
+ handled++;
74
+ });
75
+ });
76
+ }
77
+
78
+ // 2. Notify the manual subscribers.
79
+ callbacks.forEach((callback) => callback(newValue, oldValue));
80
+
81
+ // 3. Re-render the app when nothing was bound, or when the last render pass
82
+ // also read this key structurally (a template literal, a conditional, a
83
+ // list): the surgical patch above cannot change what the component computes.
84
+ if (handled === 0 || isStructuralRead(changedKey)) triggerRender();
85
+ });
86
+
87
+ if (typeof window !== "undefined") {
88
+ if (!window._useStateCleanups) window._useStateCleanups = [];
89
+ window._useStateCleanups.push(unsubscribe);
90
+ }
91
+ };
92
+
38
93
  /**
39
94
  * Registers a widget instance so it receives automatic prop updates whenever
40
95
  * the state value associated with `key` changes.
@@ -45,50 +100,49 @@ const triggerRender = () => {
45
100
  * @returns {void}
46
101
  */
47
102
  export const registerWidget = (key, widget, propName) => {
48
- if (!widgetsByKey.has(key)) {
49
- widgetsByKey.set(key, new Set());
103
+ // Two levels — key -> widget -> prop names — so looking a widget up is O(1).
104
+ // A flat Set with a linear scan made registering N widgets on the same key
105
+ // O(N^2): 1,600 widgets on one key cost ~44 ms.
106
+ let perWidget = widgetsByKey.get(key);
107
+ if (!perWidget) {
108
+ perWidget = new Map();
109
+ widgetsByKey.set(key, perWidget);
110
+ }
111
+ let propNames = perWidget.get(widget);
112
+ if (!propNames) {
113
+ propNames = new Set();
114
+ perWidget.set(widget, propNames);
50
115
  }
51
- widgetsByKey.get(key).add({ widget, propName });
116
+ // Idempotent: rebuilding the same widget position must not stack duplicates.
117
+ const already = propNames.has(propName);
118
+ propNames.add(propName);
52
119
 
53
- // Set initial value if the state already exists
120
+ // Push the current value straight away when the widget can already take it.
121
+ // (During construction `update` does not exist yet — the value has just been
122
+ // applied by the factory, so there is nothing to push.)
54
123
  const currentValue = getRam(key);
55
124
  if (currentValue !== undefined && widget && widget.update) {
56
125
  widget.update({ [propName]: currentValue });
57
126
  }
58
127
 
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();
128
+ if (already) return;
129
+
130
+ // Release the binding through `onUnmount`, which the framework fires on BOTH
131
+ // teardown paths. Patching `_cleanup` (the previous approach) leaked: the
132
+ // lifecycle observer never calls `_cleanup`, so a widget removed without a full
133
+ // app rebuild stayed registered forever and kept receiving updates.
134
+ const release = () => {
135
+ const perWidget = widgetsByKey.get(key);
136
+ const props = perWidget && perWidget.get(widget);
137
+ if (!props) return;
138
+ props.delete(propName);
139
+ if (props.size === 0) perWidget.delete(widget);
140
+ if (perWidget.size === 0) widgetsByKey.delete(key);
72
141
  };
73
- };
74
142
 
75
- /**
76
- * Removes a specific widget from the automatic-update registry for a given key.
77
- *
78
- * @param {string} key - The state key the widget was registered under.
79
- * @param {Object} widget - The widget instance to unregister.
80
- * @returns {void}
81
- */
82
- 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
- }
143
+ // The factory installs lifecycle hooks before it binds reactive props, so a
144
+ // widget reaching this point always owns `onUnmount`.
145
+ widget.onUnmount(release);
92
146
  };
93
147
 
94
148
  /**
@@ -110,43 +164,21 @@ export const unregisterWidget = (key, widget) => {
110
164
  */
111
165
  export const useState = (key, initialState, widget = null, propName = null) => {
112
166
  // Initialize the state in RamStore if it does not exist yet
113
- let currentValue = getRam(key);
114
- if (currentValue === undefined || currentValue === null) {
115
- currentValue =
116
- typeof initialState === "function" ? initialState() : initialState;
117
- saveRam(key, currentValue);
167
+ const existing = getRam(key);
168
+ if (existing === undefined || existing === null) {
169
+ saveRam(
170
+ key,
171
+ typeof initialState === "function" ? initialState() : initialState,
172
+ );
118
173
  }
119
174
 
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
- }
175
+ // Register the key so the shared dispatcher knows it has listeners. Previously
176
+ // every key installed its OWN RamStore subscriber, and RamStore notifies all of
177
+ // them on every save — so one state change cost O(number of keys), each callback
178
+ // rejecting the key immediately. One dispatcher makes it O(1) + the work for the
179
+ // key that actually changed.
180
+ if (!subscribers.has(key)) subscribers.set(key, new Set());
181
+ ensureRamDispatcher();
150
182
 
151
183
  // 🔥 Register the widget for automatic updates if one was provided
152
184
  if (widget && propName) {
@@ -161,19 +193,18 @@ export const useState = (key, initialState, widget = null, propName = null) => {
161
193
  * @returns {void}
162
194
  */
163
195
  const setState = (newValueOrUpdater) => {
164
- const oldValue = currentValue;
165
- let newValue;
196
+ // Read the store rather than a value captured when the hook ran: a setter
197
+ // kept across renders would otherwise compare against a stale `oldValue` and
198
+ // skip the dispatch when an external `saveState` had already moved the key to
199
+ // the value being set — leaving the store, the widgets and the callbacks
200
+ // disagreeing about what the state is.
201
+ const oldValue = getRam(key);
202
+ const newValue =
203
+ typeof newValueOrUpdater === "function"
204
+ ? newValueOrUpdater(oldValue)
205
+ : newValueOrUpdater;
166
206
 
167
- if (typeof newValueOrUpdater === "function") {
168
- newValue = newValueOrUpdater(oldValue);
169
- } else {
170
- newValue = newValueOrUpdater;
171
- }
172
-
173
- if (oldValue !== newValue) {
174
- currentValue = newValue;
175
- saveRam(key, currentValue);
176
- }
207
+ if (oldValue !== newValue) saveRam(key, newValue);
177
208
  };
178
209
 
179
210
  /**
@@ -188,24 +219,15 @@ export const useState = (key, initialState, widget = null, propName = null) => {
188
219
  }
189
220
  subscribers.get(key).add(callback);
190
221
  return () => {
191
- if (subscribers.has(key)) {
192
- subscribers.get(key).delete(callback);
193
- }
222
+ subscribers.get(key)?.delete(callback);
194
223
  };
195
224
  };
196
225
 
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
- };
226
+ // 🔥 A LIVE reactive value: reads the store on every access (so it never goes
227
+ // stale) and carries the key, so WidgetFactory can auto-bind any prop it is
228
+ // passed to. That binding is what makes a state change update **only the widget
229
+ // that uses it** instead of rebuilding the app.
230
+ const reactiveValue = createReactiveValue(() => getRam(key), key);
209
231
 
210
232
  // Return an array matching React's useState pattern: [value, setter, subscriber]
211
233
  return [reactiveValue, setState, subscribe];
@@ -224,7 +246,9 @@ export const useWatchState = (key, callback) => {
224
246
  subscribers.set(key, new Set());
225
247
  }
226
248
  subscribers.get(key).add(callback);
227
- return () => subscribers.get(key).delete(callback);
249
+ return () => {
250
+ subscribers.get(key)?.delete(callback);
251
+ };
228
252
  };
229
253
 
230
254
  /**
@@ -242,6 +266,9 @@ export const cleanupUseState = () => {
242
266
  }
243
267
  subscribers.clear();
244
268
  widgetsByKey.clear();
269
+ resetStructuralReads();
270
+ // The dispatcher was unsubscribed above, so a later call must reinstall it.
271
+ ramDispatcherReady = false;
245
272
  globalRender = null;
246
273
  if (renderTimeout) clearTimeout(renderTimeout);
247
274
  };
@@ -250,12 +277,23 @@ export const cleanupUseState = () => {
250
277
  * Returns diagnostic statistics about the current state of the useState module.
251
278
  * Useful for debugging subscription leaks or widget registration issues.
252
279
  *
253
- * @returns {{ subscribersCount: number, widgetsCount: number, globalRender: boolean }}
280
+ * @returns {{ stateKeys: number, boundWidgets: number, watchers: number, globalRender: boolean }}
254
281
  */
255
282
  export const getUseStateStats = () => {
283
+ // `widgetsByKey` maps key -> widget -> prop names, so its size is the number of
284
+ // watched *keys*, not of widgets. Count one per widget.
285
+ let boundWidgets = 0;
286
+ let watchers = 0;
287
+ widgetsByKey.forEach((perWidget) => {
288
+ boundWidgets += perWidget.size;
289
+ });
290
+ subscribers.forEach((callbacks) => {
291
+ watchers += callbacks.size;
292
+ });
256
293
  return {
257
- subscribersCount: subscribers.size,
258
- widgetsCount: widgetsByKey.size,
294
+ stateKeys: subscribers.size,
295
+ boundWidgets,
296
+ watchers,
259
297
  globalRender: !!globalRender,
260
298
  };
261
299
  };
@@ -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 };