@saolabs/client 1.0.0 → 1.0.2

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 (378) hide show
  1. package/README.md +52 -195
  2. package/dist/index.d.ts +20 -5
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +14 -3
  5. package/dist/index.js.map +1 -1
  6. package/dist/plugins.d.ts +2 -2
  7. package/dist/plugins.js +2 -2
  8. package/dist/src/core/app/Application.d.ts +17 -15
  9. package/dist/src/core/app/Application.d.ts.map +1 -1
  10. package/dist/src/core/app/Application.js +41 -18
  11. package/dist/src/core/app/Application.js.map +1 -1
  12. package/dist/src/core/bootstrap/app.d.ts +1 -1
  13. package/dist/src/core/bootstrap/app.d.ts.map +1 -1
  14. package/dist/src/core/bootstrap/app.js +74 -4
  15. package/dist/src/core/bootstrap/app.js.map +1 -1
  16. package/dist/src/core/bootstrap/bundle.d.ts +41 -0
  17. package/dist/src/core/bootstrap/bundle.d.ts.map +1 -0
  18. package/dist/src/core/bootstrap/bundle.js +98 -0
  19. package/dist/src/core/bootstrap/bundle.js.map +1 -0
  20. package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts +1 -1
  21. package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts.map +1 -1
  22. package/dist/src/core/bootstrap/providers/ApiServiceProvider.js +3 -3
  23. package/dist/src/core/bootstrap/providers/ApiServiceProvider.js.map +1 -1
  24. package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts +1 -1
  25. package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts.map +1 -1
  26. package/dist/src/core/bootstrap/providers/CoreServiceProvider.js +11 -8
  27. package/dist/src/core/bootstrap/providers/CoreServiceProvider.js.map +1 -1
  28. package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts +3 -1
  29. package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts.map +1 -1
  30. package/dist/src/core/bootstrap/providers/HelperServiceProvider.js +8 -3
  31. package/dist/src/core/bootstrap/providers/HelperServiceProvider.js.map +1 -1
  32. package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts +2 -1
  33. package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts.map +1 -1
  34. package/dist/src/core/bootstrap/providers/RouteServiceProvider.js +14 -4
  35. package/dist/src/core/bootstrap/providers/RouteServiceProvider.js.map +1 -1
  36. package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts +4 -4
  37. package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts.map +1 -1
  38. package/dist/src/core/bootstrap/providers/ServiceProvider.js +5 -5
  39. package/dist/src/core/bootstrap/providers/ServiceProvider.js.map +1 -1
  40. package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts +1 -1
  41. package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts.map +1 -1
  42. package/dist/src/core/bootstrap/providers/ViewServiceProvider.js +3 -3
  43. package/dist/src/core/bootstrap/providers/ViewServiceProvider.js.map +1 -1
  44. package/dist/src/core/bootstrap/providers/default-providers.d.ts +1 -1
  45. package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
  46. package/dist/src/core/bootstrap/providers/default-providers.js +44 -11
  47. package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
  48. package/dist/src/core/bootstrap/providers/index.d.ts +9 -9
  49. package/dist/src/core/bootstrap/providers/index.d.ts.map +1 -1
  50. package/dist/src/core/bootstrap/providers/index.js +8 -8
  51. package/dist/src/core/bootstrap/providers/index.js.map +1 -1
  52. package/dist/src/core/bootstrap/providers/provider-order.d.ts +1 -1
  53. package/dist/src/core/bootstrap/providers/provider-order.d.ts.map +1 -1
  54. package/dist/src/core/bootstrap/ssr.d.ts +47 -0
  55. package/dist/src/core/bootstrap/ssr.d.ts.map +1 -0
  56. package/dist/src/core/bootstrap/ssr.js +125 -0
  57. package/dist/src/core/bootstrap/ssr.js.map +1 -0
  58. package/dist/src/core/cache/ViewCache.d.ts +1 -1
  59. package/dist/src/core/cache/ViewCache.d.ts.map +1 -1
  60. package/dist/src/core/cache/index.d.ts +1 -1
  61. package/dist/src/core/cache/index.d.ts.map +1 -1
  62. package/dist/src/core/cache/index.js +1 -1
  63. package/dist/src/core/cache/index.js.map +1 -1
  64. package/dist/src/core/contracts/ApplicationInterface.d.ts +13 -11
  65. package/dist/src/core/contracts/ApplicationInterface.d.ts.map +1 -1
  66. package/dist/src/core/contracts/BlockInterface.d.ts +10 -10
  67. package/dist/src/core/contracts/BlockInterface.d.ts.map +1 -1
  68. package/dist/src/core/contracts/BundleInterface.d.ts +38 -0
  69. package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -0
  70. package/dist/src/core/contracts/BundleInterface.js +2 -0
  71. package/dist/src/core/contracts/BundleInterface.js.map +1 -0
  72. package/dist/src/core/contracts/ComponentInterface.d.ts +7 -6
  73. package/dist/src/core/contracts/ComponentInterface.d.ts.map +1 -1
  74. package/dist/src/core/contracts/ElementInterface.d.ts +91 -27
  75. package/dist/src/core/contracts/ElementInterface.d.ts.map +1 -1
  76. package/dist/src/core/contracts/HeadServiceInterface.d.ts +37 -0
  77. package/dist/src/core/contracts/HeadServiceInterface.d.ts.map +1 -0
  78. package/dist/src/core/contracts/HeadServiceInterface.js +3 -0
  79. package/dist/src/core/contracts/HeadServiceInterface.js.map +1 -0
  80. package/dist/src/core/contracts/HelperInterface.d.ts +9 -1
  81. package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
  82. package/dist/src/core/contracts/HttpServiceInterface.d.ts +3 -0
  83. package/dist/src/core/contracts/HttpServiceInterface.d.ts.map +1 -1
  84. package/dist/src/core/contracts/MarkerInterface.d.ts +29 -44
  85. package/dist/src/core/contracts/MarkerInterface.d.ts.map +1 -1
  86. package/dist/src/core/contracts/ReactiveInterface.d.ts +9 -9
  87. package/dist/src/core/contracts/ReactiveInterface.d.ts.map +1 -1
  88. package/dist/src/core/contracts/RouterInterface.d.ts +5 -2
  89. package/dist/src/core/contracts/RouterInterface.d.ts.map +1 -1
  90. package/dist/src/core/contracts/SectionInterface.d.ts +48 -0
  91. package/dist/src/core/contracts/SectionInterface.d.ts.map +1 -0
  92. package/dist/src/core/contracts/SectionInterface.js +2 -0
  93. package/dist/src/core/contracts/SectionInterface.js.map +1 -0
  94. package/dist/src/core/contracts/ServiceProviderInterface.d.ts +3 -3
  95. package/dist/src/core/contracts/ServiceProviderInterface.d.ts.map +1 -1
  96. package/dist/src/core/contracts/ViewControllerInterface.d.ts +116 -15
  97. package/dist/src/core/contracts/ViewControllerInterface.d.ts.map +1 -1
  98. package/dist/src/core/contracts/ViewInterface.d.ts +31 -5
  99. package/dist/src/core/contracts/ViewInterface.d.ts.map +1 -1
  100. package/dist/src/core/contracts/ViewManagerInterface.d.ts +47 -5
  101. package/dist/src/core/contracts/ViewManagerInterface.d.ts.map +1 -1
  102. package/dist/src/core/contracts/ViewStateInterface.d.ts +39 -2
  103. package/dist/src/core/contracts/ViewStateInterface.d.ts.map +1 -1
  104. package/dist/src/core/contracts/common.d.ts +3 -3
  105. package/dist/src/core/contracts/common.js +3 -3
  106. package/dist/src/core/contracts/utils.d.ts +19 -19
  107. package/dist/src/core/contracts/utils.d.ts.map +1 -1
  108. package/dist/src/core/contracts/views.d.ts +1 -22
  109. package/dist/src/core/contracts/views.d.ts.map +1 -1
  110. package/dist/src/core/devtools/hook.d.ts +66 -0
  111. package/dist/src/core/devtools/hook.d.ts.map +1 -0
  112. package/dist/src/core/devtools/hook.js +114 -0
  113. package/dist/src/core/devtools/hook.js.map +1 -0
  114. package/dist/src/core/devtools/index.d.ts +34 -0
  115. package/dist/src/core/devtools/index.d.ts.map +1 -0
  116. package/dist/src/core/devtools/index.js +33 -0
  117. package/dist/src/core/devtools/index.js.map +1 -0
  118. package/dist/src/core/devtools/inspector.d.ts +31 -0
  119. package/dist/src/core/devtools/inspector.d.ts.map +1 -0
  120. package/dist/src/core/devtools/inspector.js +249 -0
  121. package/dist/src/core/devtools/inspector.js.map +1 -0
  122. package/dist/src/core/elements/Block.d.ts +9 -7
  123. package/dist/src/core/elements/Block.d.ts.map +1 -1
  124. package/dist/src/core/elements/Block.js +33 -9
  125. package/dist/src/core/elements/Block.js.map +1 -1
  126. package/dist/src/core/elements/BlockOutlet.d.ts +19 -8
  127. package/dist/src/core/elements/BlockOutlet.d.ts.map +1 -1
  128. package/dist/src/core/elements/BlockOutlet.js +57 -13
  129. package/dist/src/core/elements/BlockOutlet.js.map +1 -1
  130. package/dist/src/core/elements/Component.d.ts +83 -11
  131. package/dist/src/core/elements/Component.d.ts.map +1 -1
  132. package/dist/src/core/elements/Component.js +334 -13
  133. package/dist/src/core/elements/Component.js.map +1 -1
  134. package/dist/src/core/elements/ForeachSlotCache.d.ts +95 -0
  135. package/dist/src/core/elements/ForeachSlotCache.d.ts.map +1 -0
  136. package/dist/src/core/elements/ForeachSlotCache.js +149 -0
  137. package/dist/src/core/elements/ForeachSlotCache.js.map +1 -0
  138. package/dist/src/core/elements/Fragment.d.ts +19 -13
  139. package/dist/src/core/elements/Fragment.d.ts.map +1 -1
  140. package/dist/src/core/elements/Fragment.js +43 -20
  141. package/dist/src/core/elements/Fragment.js.map +1 -1
  142. package/dist/src/core/elements/Html.d.ts +89 -12
  143. package/dist/src/core/elements/Html.d.ts.map +1 -1
  144. package/dist/src/core/elements/Html.js +707 -106
  145. package/dist/src/core/elements/Html.js.map +1 -1
  146. package/dist/src/core/elements/Output.d.ts +34 -10
  147. package/dist/src/core/elements/Output.d.ts.map +1 -1
  148. package/dist/src/core/elements/Output.js +141 -26
  149. package/dist/src/core/elements/Output.js.map +1 -1
  150. package/dist/src/core/elements/Reactive.d.ts +85 -14
  151. package/dist/src/core/elements/Reactive.d.ts.map +1 -1
  152. package/dist/src/core/elements/Reactive.js +315 -41
  153. package/dist/src/core/elements/Reactive.js.map +1 -1
  154. package/dist/src/core/elements/TextElement.d.ts +11 -7
  155. package/dist/src/core/elements/TextElement.d.ts.map +1 -1
  156. package/dist/src/core/elements/TextElement.js +18 -11
  157. package/dist/src/core/elements/TextElement.js.map +1 -1
  158. package/dist/src/core/elements/Wrapper.d.ts +24 -13
  159. package/dist/src/core/elements/Wrapper.d.ts.map +1 -1
  160. package/dist/src/core/elements/Wrapper.js +65 -8
  161. package/dist/src/core/elements/Wrapper.js.map +1 -1
  162. package/dist/src/core/elements/Yield.d.ts +10 -9
  163. package/dist/src/core/elements/Yield.d.ts.map +1 -1
  164. package/dist/src/core/elements/Yield.js +31 -19
  165. package/dist/src/core/elements/Yield.js.map +1 -1
  166. package/dist/src/core/elements/helpers.d.ts +7 -7
  167. package/dist/src/core/elements/helpers.d.ts.map +1 -1
  168. package/dist/src/core/elements/helpers.js +4 -4
  169. package/dist/src/core/elements/index.d.ts +12 -11
  170. package/dist/src/core/elements/index.d.ts.map +1 -1
  171. package/dist/src/core/elements/index.js +12 -11
  172. package/dist/src/core/elements/index.js.map +1 -1
  173. package/dist/src/core/elements/reconcileChildren.d.ts +7 -0
  174. package/dist/src/core/elements/reconcileChildren.d.ts.map +1 -0
  175. package/dist/src/core/elements/reconcileChildren.js +74 -0
  176. package/dist/src/core/elements/reconcileChildren.js.map +1 -0
  177. package/dist/src/core/hellpers/utils.d.ts +2 -2
  178. package/dist/src/core/hellpers/utils.js +4 -4
  179. package/dist/src/core/helpers/ApiClient.d.ts +58 -0
  180. package/dist/src/core/helpers/ApiClient.d.ts.map +1 -0
  181. package/dist/src/core/helpers/ApiClient.js +131 -0
  182. package/dist/src/core/helpers/ApiClient.js.map +1 -0
  183. package/dist/src/core/helpers/app.d.ts +3 -0
  184. package/dist/src/core/helpers/app.d.ts.map +1 -0
  185. package/dist/src/core/helpers/app.js +63 -0
  186. package/dist/src/core/helpers/app.js.map +1 -0
  187. package/dist/src/core/helpers/transition.d.ts +34 -0
  188. package/dist/src/core/helpers/transition.d.ts.map +1 -0
  189. package/dist/src/core/helpers/transition.js +118 -0
  190. package/dist/src/core/helpers/transition.js.map +1 -0
  191. package/dist/src/core/helpers/utils.d.ts +26 -0
  192. package/dist/src/core/helpers/utils.d.ts.map +1 -0
  193. package/dist/src/core/helpers/utils.js +96 -0
  194. package/dist/src/core/helpers/utils.js.map +1 -0
  195. package/dist/src/core/helpers/view.d.ts +39 -0
  196. package/dist/src/core/helpers/view.d.ts.map +1 -0
  197. package/dist/src/core/helpers/view.js +286 -0
  198. package/dist/src/core/helpers/view.js.map +1 -0
  199. package/dist/src/core/routers/Router.d.ts +108 -7
  200. package/dist/src/core/routers/Router.d.ts.map +1 -1
  201. package/dist/src/core/routers/Router.js +444 -68
  202. package/dist/src/core/routers/Router.js.map +1 -1
  203. package/dist/src/core/routers/index.d.ts +2 -2
  204. package/dist/src/core/routers/index.d.ts.map +1 -1
  205. package/dist/src/core/routers/index.js +1 -1
  206. package/dist/src/core/routers/index.js.map +1 -1
  207. package/dist/src/core/services/AssetManager.d.ts +112 -0
  208. package/dist/src/core/services/AssetManager.d.ts.map +1 -0
  209. package/dist/src/core/services/AssetManager.js +361 -0
  210. package/dist/src/core/services/AssetManager.js.map +1 -0
  211. package/dist/src/core/services/BlockManager.d.ts +73 -2
  212. package/dist/src/core/services/BlockManager.d.ts.map +1 -1
  213. package/dist/src/core/services/BlockManager.js +270 -48
  214. package/dist/src/core/services/BlockManager.js.map +1 -1
  215. package/dist/src/core/services/DomService.js +3 -3
  216. package/dist/src/core/services/DomService.js.map +1 -1
  217. package/dist/src/core/services/HeadService.d.ts +24 -0
  218. package/dist/src/core/services/HeadService.d.ts.map +1 -0
  219. package/dist/src/core/services/HeadService.js +120 -0
  220. package/dist/src/core/services/HeadService.js.map +1 -0
  221. package/dist/src/core/services/HelperService.d.ts +14 -3
  222. package/dist/src/core/services/HelperService.d.ts.map +1 -1
  223. package/dist/src/core/services/HelperService.js +32 -2
  224. package/dist/src/core/services/HelperService.js.map +1 -1
  225. package/dist/src/core/services/HttpService.d.ts +8 -0
  226. package/dist/src/core/services/HttpService.d.ts.map +1 -1
  227. package/dist/src/core/services/HttpService.js +121 -47
  228. package/dist/src/core/services/HttpService.js.map +1 -1
  229. package/dist/src/core/services/MarkerRegistry.d.ts +51 -8
  230. package/dist/src/core/services/MarkerRegistry.d.ts.map +1 -1
  231. package/dist/src/core/services/MarkerRegistry.js +135 -14
  232. package/dist/src/core/services/MarkerRegistry.js.map +1 -1
  233. package/dist/src/core/services/PageCache.d.ts +101 -0
  234. package/dist/src/core/services/PageCache.d.ts.map +1 -0
  235. package/dist/src/core/services/PageCache.js +184 -0
  236. package/dist/src/core/services/PageCache.js.map +1 -0
  237. package/dist/src/core/services/SectionManager.d.ts +100 -0
  238. package/dist/src/core/services/SectionManager.d.ts.map +1 -0
  239. package/dist/src/core/services/SectionManager.js +408 -0
  240. package/dist/src/core/services/SectionManager.js.map +1 -0
  241. package/dist/src/core/services/StorageService.js +1 -1
  242. package/dist/src/core/services/TranslationService.d.ts +21 -0
  243. package/dist/src/core/services/TranslationService.d.ts.map +1 -0
  244. package/dist/src/core/services/TranslationService.js +95 -0
  245. package/dist/src/core/services/TranslationService.js.map +1 -0
  246. package/dist/src/core/services/index.d.ts +11 -8
  247. package/dist/src/core/services/index.d.ts.map +1 -1
  248. package/dist/src/core/services/index.js +11 -8
  249. package/dist/src/core/services/index.js.map +1 -1
  250. package/dist/src/core/types/utils.d.ts +8 -8
  251. package/dist/src/core/types/utils.d.ts.map +1 -1
  252. package/dist/src/core/view/LoopContext.d.ts +21 -1
  253. package/dist/src/core/view/LoopContext.d.ts.map +1 -1
  254. package/dist/src/core/view/LoopContext.js +36 -0
  255. package/dist/src/core/view/LoopContext.js.map +1 -1
  256. package/dist/src/core/view/ResourceScope.d.ts +38 -0
  257. package/dist/src/core/view/ResourceScope.d.ts.map +1 -0
  258. package/dist/src/core/view/ResourceScope.js +167 -0
  259. package/dist/src/core/view/ResourceScope.js.map +1 -0
  260. package/dist/src/core/view/Section.d.ts +17 -32
  261. package/dist/src/core/view/Section.d.ts.map +1 -1
  262. package/dist/src/core/view/Section.js +14 -55
  263. package/dist/src/core/view/Section.js.map +1 -1
  264. package/dist/src/core/view/View.d.ts +43 -7
  265. package/dist/src/core/view/View.d.ts.map +1 -1
  266. package/dist/src/core/view/View.js +22 -4
  267. package/dist/src/core/view/View.js.map +1 -1
  268. package/dist/src/core/view/ViewController.d.ts +313 -40
  269. package/dist/src/core/view/ViewController.d.ts.map +1 -1
  270. package/dist/src/core/view/ViewController.js +1002 -147
  271. package/dist/src/core/view/ViewController.js.map +1 -1
  272. package/dist/src/core/view/ViewManager.d.ts +318 -27
  273. package/dist/src/core/view/ViewManager.d.ts.map +1 -1
  274. package/dist/src/core/view/ViewManager.js +1199 -258
  275. package/dist/src/core/view/ViewManager.js.map +1 -1
  276. package/dist/src/core/view/ViewState.d.ts +185 -6
  277. package/dist/src/core/view/ViewState.d.ts.map +1 -1
  278. package/dist/src/core/view/ViewState.js +851 -123
  279. package/dist/src/core/view/ViewState.js.map +1 -1
  280. package/dist/src/core/view/index.d.ts +5 -5
  281. package/dist/src/core/view/index.d.ts.map +1 -1
  282. package/dist/src/core/view/index.js +5 -5
  283. package/dist/src/core/view/index.js.map +1 -1
  284. package/dist/src/testing/index.d.ts +69 -0
  285. package/dist/src/testing/index.d.ts.map +1 -0
  286. package/dist/src/testing/index.js +140 -0
  287. package/dist/src/testing/index.js.map +1 -0
  288. package/index.ts +29 -5
  289. package/package.json +30 -21
  290. package/plugins.ts +2 -2
  291. package/src/core/app/Application.ts +58 -31
  292. package/src/core/bootstrap/app.ts +80 -6
  293. package/src/core/bootstrap/bundle.ts +101 -0
  294. package/src/core/bootstrap/providers/ApiServiceProvider.ts +4 -4
  295. package/src/core/bootstrap/providers/CoreServiceProvider.ts +14 -8
  296. package/src/core/bootstrap/providers/HelperServiceProvider.ts +8 -3
  297. package/src/core/bootstrap/providers/RouteServiceProvider.ts +15 -4
  298. package/src/core/bootstrap/providers/ServiceProvider.ts +8 -8
  299. package/src/core/bootstrap/providers/ViewServiceProvider.ts +3 -3
  300. package/src/core/bootstrap/providers/default-providers.ts +51 -16
  301. package/src/core/bootstrap/providers/index.ts +9 -9
  302. package/src/core/bootstrap/providers/provider-order.test.ts +16 -27
  303. package/src/core/bootstrap/providers/provider-order.ts +1 -1
  304. package/src/core/bootstrap/ssr.ts +136 -0
  305. package/src/core/cache/ViewCache.ts +1 -1
  306. package/src/core/cache/index.ts +1 -1
  307. package/src/core/contracts/ApplicationInterface.ts +13 -11
  308. package/src/core/contracts/BlockInterface.ts +18 -18
  309. package/src/core/contracts/BundleInterface.ts +40 -0
  310. package/src/core/contracts/ComponentInterface.ts +7 -6
  311. package/src/core/contracts/ElementInterface.ts +95 -27
  312. package/src/core/contracts/HeadServiceInterface.ts +41 -0
  313. package/src/core/contracts/HelperInterface.ts +9 -1
  314. package/src/core/contracts/HttpServiceInterface.ts +3 -0
  315. package/src/core/contracts/MarkerInterface.ts +22 -49
  316. package/src/core/contracts/ReactiveInterface.ts +9 -9
  317. package/src/core/contracts/RouterInterface.ts +5 -2
  318. package/src/core/contracts/SectionInterface.ts +49 -0
  319. package/src/core/contracts/ServiceProviderInterface.ts +3 -3
  320. package/src/core/contracts/ViewControllerInterface.ts +124 -16
  321. package/src/core/contracts/ViewInterface.ts +34 -5
  322. package/src/core/contracts/ViewManagerInterface.ts +40 -5
  323. package/src/core/contracts/ViewStateInterface.ts +39 -2
  324. package/src/core/contracts/common.ts +3 -3
  325. package/src/core/contracts/utils.ts +19 -19
  326. package/src/core/contracts/views.ts +2 -22
  327. package/src/core/devtools/hook.ts +142 -0
  328. package/src/core/devtools/index.ts +38 -0
  329. package/src/core/devtools/inspector.ts +270 -0
  330. package/src/core/elements/Block.ts +43 -21
  331. package/src/core/elements/BlockOutlet.ts +61 -21
  332. package/src/core/elements/Component.ts +352 -18
  333. package/src/core/elements/ElementManager.ts +9 -9
  334. package/src/core/elements/ForeachSlotCache.ts +170 -0
  335. package/src/core/elements/Fragment.ts +50 -32
  336. package/src/core/elements/Html.ts +696 -117
  337. package/src/core/elements/Output.ts +147 -30
  338. package/src/core/elements/Reactive.ts +329 -52
  339. package/src/core/elements/TextElement.ts +25 -16
  340. package/src/core/elements/Wrapper.ts +84 -18
  341. package/src/core/elements/Yield.ts +35 -24
  342. package/src/core/elements/helpers.ts +12 -12
  343. package/src/core/elements/index.ts +25 -24
  344. package/src/core/elements/reconcileChildren.ts +70 -0
  345. package/src/core/{hellpers → helpers}/ApiClient.ts +8 -1
  346. package/src/core/{hellpers → helpers}/app.ts +13 -2
  347. package/src/core/helpers/transition.ts +127 -0
  348. package/src/core/{hellpers → helpers}/utils.ts +4 -4
  349. package/src/core/helpers/view.ts +283 -0
  350. package/src/core/routers/Router.ts +458 -64
  351. package/src/core/routers/index.ts +2 -2
  352. package/src/core/services/AssetManager.ts +387 -0
  353. package/src/core/services/BlockManager.ts +272 -50
  354. package/src/core/services/DomService.ts +3 -3
  355. package/src/core/services/HeadService.ts +124 -0
  356. package/src/core/services/HelperService.ts +34 -4
  357. package/src/core/services/HttpService.ts +116 -55
  358. package/src/core/services/MarkerRegistry.ts +142 -16
  359. package/src/core/services/PageCache.ts +239 -0
  360. package/src/core/services/SectionManager.ts +387 -0
  361. package/src/core/services/StorageService.ts +1 -1
  362. package/src/core/services/TranslationService.ts +91 -0
  363. package/src/core/services/index.ts +11 -8
  364. package/src/core/types/utils.ts +8 -8
  365. package/src/core/view/LoopContext.ts +38 -1
  366. package/src/core/view/ResourceScope.ts +174 -0
  367. package/src/core/view/Section.ts +21 -68
  368. package/src/core/view/View.ts +57 -8
  369. package/src/core/view/ViewController.ts +1070 -186
  370. package/src/core/view/ViewManager.ts +1346 -272
  371. package/src/core/view/ViewState.ts +834 -116
  372. package/src/core/view/index.ts +5 -5
  373. package/src/testing/index.ts +184 -0
  374. package/dist/src/core/services/MarkerService.d.ts +0 -299
  375. package/dist/src/core/services/MarkerService.d.ts.map +0 -1
  376. package/dist/src/core/services/MarkerService.js +0 -723
  377. package/dist/src/core/services/MarkerService.js.map +0 -1
  378. package/src/core/services/MarkerService.ts +0 -923
@@ -1,5 +1,19 @@
1
- import type { ViewControllerInterface } from "../contracts/ViewControllerInterface";
2
- import type { StateManagerInterface, ViewStateInterface, StateItem, StateListener, MultiKeyStateListener } from "../contracts/ViewStateInterface";
1
+ import type { ViewControllerInterface } from "../contracts/ViewControllerInterface.js";
2
+ import type { StateManagerInterface, ViewStateInterface, StateItem, StateListener } from "../contracts/ViewStateInterface.js";
3
+ import devtools from "../devtools/hook.js";
4
+
5
+ type MutationChannel = {
6
+ notify: (() => void) | null;
7
+ root: any;
8
+ nodes: Set<any>;
9
+ };
10
+
11
+ type Subscription = {
12
+ keys: Set<string>;
13
+ callback: StateListener;
14
+ multi: boolean;
15
+ active: boolean;
16
+ };
3
17
 
4
18
  /**
5
19
  * StateManager — manages reactive state for a ViewController.
@@ -21,14 +35,17 @@ import type { StateManagerInterface, ViewStateInterface, StateItem, StateListene
21
35
  */
22
36
  export class StateManager implements StateManagerInterface {
23
37
  private states: Record<string | number, StateItem> = {};
24
- private listeners = new Map<string | number, StateListener[]>();
25
- private multiKeyListeners: MultiKeyStateListener[] = [];
38
+ private subscriptions = new Map<string, Set<Subscription>>();
26
39
  private pendingChanges = new Set<string | number>();
40
+ private versions = new Map<string, number>();
27
41
  private stateIndex = 0;
28
42
  private flushRAF: number | null = null;
29
43
  private hasPendingFlush = false;
30
44
  private isFlushing = false;
31
45
  private _isDestroyed = false;
46
+ // Browsers opt in explicitly; production never scans unrelated state objects.
47
+ private mutationDiagnostics = !!(globalThis as any).process?.env?.NODE_ENV
48
+ && (globalThis as any).process.env.NODE_ENV !== 'production';
32
49
 
33
50
  /** Flag — cho phép update state qua update$xxx chỉ trước lock */
34
51
  private _canUpdateStateByKey: boolean = true;
@@ -80,8 +97,10 @@ export class StateManager implements StateManagerInterface {
80
97
  updateRealState(stateMap: Record<string | number, any>): void {
81
98
  if (!this._canUpdateStateByKey) return;
82
99
  for (const key in stateMap) {
83
- if (stateMap.hasOwnProperty(key) && this.states[key]) {
100
+ if (stateMap.hasOwnProperty(key) && this.states[key] && !this.computedNodes.has(key)) {
84
101
  this.states[key].value = stateMap[key];
102
+ this.trackArray(key, stateMap[key]);
103
+ this.invalidateComputed(key, false);
85
104
  }
86
105
  }
87
106
  }
@@ -97,20 +116,42 @@ export class StateManager implements StateManagerInterface {
97
116
  * `viewState.count` reads/writes the state reactively.
98
117
  */
99
118
  useState(value: any, key?: string | number): [any, (newValue: any) => void, string | number] {
100
- // If key already exists, return existing state
101
- if (key !== undefined && key !== null && this.states[key]) {
102
- return [this.states[key].value, this.states[key].setValue, key];
119
+ let stateKey: string;
120
+ if (key === undefined || key === null) {
121
+ do stateKey = String(this.stateIndex++);
122
+ while (this.states[stateKey]);
123
+ } else {
124
+ stateKey = String(key);
103
125
  }
104
126
 
105
- const stateKey = String(key ?? this.stateIndex++);
127
+ // If key already exists, return existing state
128
+ if (this.states[stateKey]) {
129
+ return [this.states[stateKey].value, this.states[stateKey].setValue, stateKey];
130
+ }
106
131
 
107
132
  const setValue = (newValue: any) => {
108
133
  const oldValue = this.states[stateKey].value;
109
134
  this.states[stateKey].value = newValue;
110
- this.commitStateChange(stateKey, oldValue);
135
+ this.trackArray(stateKey, newValue);
136
+ // fromSetter: đây là đường DEV tự set (`state.x = v` / `set$x(v)`).
137
+ // Đường props plumbing (updateStateByKey) re-pass cùng ref là bình
138
+ // thường nên không cảnh báo — xem warnSameReference.
139
+ this.commitStateChange(stateKey, oldValue, true);
111
140
  };
112
141
 
113
142
  this.states[stateKey] = { value, setValue, key: stateKey };
143
+
144
+ // Gieo bản chụp NGAY khi khai báo, không đợi flush đầu tiên. `mutatedInPlace`
145
+ // coi "chưa có bản chụp" là ĐÃ ĐỔI (an toàn: thà render thừa còn hơn nuốt
146
+ // cập nhật) — thiếu baseline thì lần set-cùng-ref đầu tiên luôn bị tính là
147
+ // thay đổi, kể cả khi thật ra không đổi gì.
148
+ if (this.mutationDiagnostics && value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
149
+ this.mutationSnapshots.set(stateKey, {
150
+ ref: value,
151
+ copy: StateManager.shallowCopy(value),
152
+ });
153
+ }
154
+ this.trackArray(stateKey, value);
114
155
  this.setters[stateKey] = setValue;
115
156
 
116
157
  // Define reactive property on ViewState if not a reserved name
@@ -133,17 +174,122 @@ export class StateManager implements StateManagerInterface {
133
174
 
134
175
  // ─── State Access ───────────────────────────────────────────
135
176
 
136
- /** Register shorthand — just returns the setter */
137
- register(key: string | number, value: any): (newValue: any) => void {
177
+ /**
178
+ * Register shorthand — pre-declare a state slot, returns setter.
179
+ *
180
+ * Compiler pattern:
181
+ * const set$count = __STATE__.__.register('count');
182
+ * // initial value set later in commitConstructorData:
183
+ * update$count(0); → updateStateByKey('count', 0)
184
+ *
185
+ * Value is optional (defaults to undefined until commitConstructorData runs).
186
+ */
187
+ register(key: string | number, value?: any): (newValue: any) => void {
138
188
  return this.useState(value, key)[1];
139
189
  }
140
190
 
191
+ private computedNodes = new Map<string, { deps: string[]; dirty: () => void }>();
192
+ private computedDependents = new Map<string | number, Set<string>>();
193
+
194
+ /** Invalidate the dependency graph synchronously; evaluation remains lazy. */
195
+ private invalidateComputed(key: string | number, notify = true): void {
196
+ const seen = new Set<string | number>([key]);
197
+ const pending: (string | number)[] = [key];
198
+ for (let i = 0; i < pending.length; i++) {
199
+ for (const dependent of this.computedDependents.get(pending[i]) ?? []) {
200
+ if (seen.has(dependent)) continue;
201
+ seen.add(dependent);
202
+ this.computedNodes.get(dependent)?.dirty();
203
+ this.versions.set(dependent, (this.versions.get(dependent) ?? 0) + 1);
204
+ if (notify) (this._isPaused ? this.dirtyKeys : this.pendingChanges).add(dependent);
205
+ pending.push(dependent);
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * State dẫn xuất có memo hoá (kiểu Vue `computed`).
212
+ *
213
+ * Chỉ tính lại khi 1 trong `deps` đổi, và **lazy**: đánh dấu bẩn lúc dep
214
+ * đổi, tính thật lúc ĐỌC. Deps đổi 5 lần trong 1 batch → tính 1 lần; đổi
215
+ * mà không ai đọc → không tính.
216
+ *
217
+ * Slot nằm chung `states` với state thường nên `getStateByKey(key)`,
218
+ * `viewState[key]` và `subscribe([key])` đều dùng được — Output/Reactive
219
+ * chỉ cần `stateKeys: [key]`, không cần biết đó là computed.
220
+ *
221
+ * @example
222
+ * states.__.computed('fullName', () => `${first} ${last}`, ['first', 'last']);
223
+ * this.output('o', p, true, ['fullName'], () => states.__.getStateByKey('fullName'));
224
+ */
225
+ computed<T>(key: string, fn: () => T, deps: string[] = []): () => T {
226
+ if (this._isDestroyed) throw new Error('[ViewState] Cannot register computed after destroy.');
227
+ // Validate before replacing any edges, including indirect cycles.
228
+ const reaches = (name: string, seen = new Set<string>()): boolean => {
229
+ if (name === key) return true;
230
+ if (seen.has(name)) return false;
231
+ seen.add(name);
232
+ return (this.computedNodes.get(name)?.deps ?? []).some(dep => reaches(dep, seen));
233
+ };
234
+ if (deps.some(dep => reaches(dep))) throw new Error(`[ViewState] Computed dependency cycle: ${key}`);
235
+ const existing = this.states[key] as any;
236
+ if (existing && !existing.__computed__) throw new Error(`[ViewState] State already exists: ${key}`);
237
+ for (const dep of this.computedNodes.get(key)?.deps ?? []) {
238
+ const dependents = this.computedDependents.get(dep);
239
+ dependents?.delete(key);
240
+ if (dependents?.size === 0) this.computedDependents.delete(dep);
241
+ }
242
+ let cache: T;
243
+ let dirty = true;
244
+ let evaluating = false;
245
+ const slot: any = {
246
+ key,
247
+ __computed__: true,
248
+ setValue: () => console.warn(`[ViewState] computed("${key}") is read-only.`),
249
+ };
250
+ Object.defineProperty(slot, 'value', {
251
+ get: () => {
252
+ if (dirty) {
253
+ if (evaluating) throw new Error(`[ViewState] Recursive computed read: ${key}`);
254
+ evaluating = true;
255
+ try { cache = fn(); dirty = false; }
256
+ finally { evaluating = false; }
257
+ }
258
+ return cache;
259
+ },
260
+ set: slot.setValue,
261
+ enumerable: true,
262
+ });
263
+ this.states[key] = slot;
264
+ this.setters[key] = slot.setValue;
265
+ this.computedNodes.set(key, { deps: [...new Set(deps)], dirty: () => { dirty = true; } });
266
+ for (const dep of deps) {
267
+ if (!this.computedDependents.has(dep)) this.computedDependents.set(dep, new Set());
268
+ this.computedDependents.get(dep)!.add(key);
269
+ }
270
+ if (!existing && !this.ownProperties.includes(key)) {
271
+ Object.defineProperty(this.stateInstance, key, {
272
+ get: () => this.states[key]?.value,
273
+ set: slot.setValue,
274
+ enumerable: true,
275
+ });
276
+ }
277
+ if (existing) this.enqueueChange(key);
278
+ return () => this.getStateByKey(key) as T;
279
+ }
280
+
141
281
  /** Update state by key */
142
282
  updateStateByKey(key: string | number, value: any): any {
143
- if (!this.states[key]) return undefined;
144
- const oldValue = this.states[key].value;
145
- this.states[key].value = value;
146
- this.commitStateChange(key, oldValue);
283
+ const stateKey = String(key);
284
+ if (!this.states[stateKey]) return undefined;
285
+ if (this.computedNodes.has(stateKey)) {
286
+ this.setters[stateKey](value);
287
+ return this.states[stateKey].value;
288
+ }
289
+ const oldValue = this.states[stateKey].value;
290
+ this.states[stateKey].value = value;
291
+ this.trackArray(stateKey, value);
292
+ this.commitStateChange(stateKey, oldValue);
147
293
  return value;
148
294
  }
149
295
 
@@ -207,100 +353,223 @@ export class StateManager implements StateManagerInterface {
207
353
  key: string | number | string[] | Record<string, StateListener>,
208
354
  callback?: StateListener
209
355
  ): () => void {
210
- // Array of keys
211
- if (Array.isArray(key)) {
212
- if (key.length === 0) return () => {};
213
- if (key.length === 1 && callback) return this.subscribe(key[0], callback);
214
- if (typeof callback !== 'function') return () => {};
215
-
216
- const keys = new Set<string | number>();
217
- for (const k of key) {
218
- if (this.states[k]) keys.add(k);
219
- }
220
- if (keys.size === 0) return () => {};
221
-
222
- const listener: MultiKeyStateListener = { keys, callback, called: false };
223
- this.multiKeyListeners.push(listener);
224
-
225
- return () => {
226
- const idx = this.multiKeyListeners.indexOf(listener);
227
- if (idx !== -1) this.multiKeyListeners.splice(idx, 1);
228
- };
356
+ if (this._isDestroyed) return () => {};
357
+ if (typeof key === 'object' && key !== null && !Array.isArray(key)) {
358
+ const unsubs = Object.entries(key).map(([k, cb]) => this.subscribe(k, cb));
359
+ return () => { for (const off of unsubs) off(); };
229
360
  }
230
-
231
- // Object map of keys → callbacks
232
- if (typeof key === 'object' && key !== null) {
233
- const unsubs: Record<string, () => void> = {};
234
- for (const k in key) {
235
- unsubs[k] = this.subscribe(k, key[k]);
236
- }
237
- return () => { for (const k in unsubs) unsubs[k](); };
361
+ if (typeof callback !== 'function') return () => {};
362
+ const keys = new Set((Array.isArray(key) ? key : [key]).map(String));
363
+ if (keys.size === 0) return () => {};
364
+ const record: Subscription = { keys, callback, multi: Array.isArray(key) && key.length > 1, active: true };
365
+ for (const k of keys) {
366
+ let records = this.subscriptions.get(k);
367
+ if (!records) this.subscriptions.set(k, records = new Set());
368
+ records.add(record);
238
369
  }
370
+ return () => this.removeSubscription(record);
371
+ }
239
372
 
240
- // Single key
241
- if (typeof callback !== 'function') return () => {};
242
- if (!this.listeners.has(key)) this.listeners.set(key, []);
243
- this.listeners.get(key)!.push(callback);
244
- const index = this.listeners.get(key)!.length - 1;
245
-
246
- return () => {
247
- const listeners = this.listeners.get(key);
248
- if (listeners) {
249
- listeners.splice(index, 1);
250
- if (listeners.length === 0) this.listeners.delete(key);
251
- }
252
- };
373
+ private removeSubscription(record: Subscription): void {
374
+ if (!record.active) return;
375
+ record.active = false;
376
+ for (const k of record.keys) {
377
+ const records = this.subscriptions.get(k);
378
+ records?.delete(record);
379
+ if (records?.size === 0) this.subscriptions.delete(k);
380
+ }
253
381
  }
254
382
 
255
383
  unsubscribe(
256
384
  key: string | number | string[] | Record<string, StateListener>,
257
385
  callback?: StateListener
258
386
  ): void {
259
- if (Array.isArray(key)) {
260
- if (key.length === 0) return;
261
- if (key.length === 1) { this.unsubscribe(key[0], callback); return; }
262
-
263
- const keySet = new Set(key);
264
- if (!callback) {
265
- for (let i = this.multiKeyListeners.length - 1; i >= 0; i--) {
266
- if (this.setsEqual(this.multiKeyListeners[i].keys, keySet)) {
267
- this.multiKeyListeners.splice(i, 1);
268
- }
269
- }
270
- return;
271
- }
272
- const idx = this.multiKeyListeners.findIndex(l =>
273
- l.callback === callback && this.setsEqual(l.keys, keySet)
274
- );
275
- if (idx !== -1) this.multiKeyListeners.splice(idx, 1);
387
+ if (typeof key === 'object' && key !== null && !Array.isArray(key)) {
388
+ for (const [k, cb] of Object.entries(key)) this.unsubscribe(k, cb);
276
389
  return;
277
390
  }
391
+ const keys = new Set((Array.isArray(key) ? key : [key]).map(String));
392
+ const multi = Array.isArray(key) && key.length > 1;
393
+ const first = keys.values().next().value;
394
+ if (first === undefined) return;
395
+ for (const record of Array.from(this.subscriptions.get(first) ?? [])) {
396
+ if (record.multi === multi && this.setsEqual(record.keys, keys) && (!callback || record.callback === callback)) {
397
+ this.removeSubscription(record);
398
+ if (callback) break; // remove one registration, as before
399
+ }
400
+ }
401
+ }
278
402
 
279
- if (typeof key === 'object' && key !== null) {
280
- for (const k in key) this.unsubscribe(k, key[k]);
281
- return;
403
+ /** Expensive checks for mutations not intercepted by accessors/mutators. */
404
+ setMutationDiagnostics(enabled: boolean): void {
405
+ this.mutationDiagnostics = enabled;
406
+ this.mutationSnapshots.clear();
407
+ this.hookHandledKeys.clear();
408
+ if (enabled) this.detectExternalMutation();
409
+ }
410
+
411
+ /** Synchronous revision, including changes not flushed to DOM yet. */
412
+ getStateVersion(key: string): number {
413
+ return this.versions.get(key) ?? 0;
414
+ }
415
+
416
+ // ─── Pause / Resume (dirty tracking) ────────────────────────
417
+ // Thiết kế: ROUTE_RENDER_FLOW.md §7, §8.2-8.3.
418
+ // Khi paused: state VẪN nhận giá trị mới, nhưng không notify listener —
419
+ // key đổi được ghi vào dirtyKeys. resume() flush đúng các key dirty.
420
+
421
+ private _isPaused = false;
422
+ private dirtyKeys = new Set<string | number>();
423
+
424
+ get isPaused(): boolean {
425
+ return this._isPaused;
426
+ }
427
+
428
+ /** Chuyển sang dirty-mode. Flush nốt pending changes trước để DOM là snapshot nhất quán. */
429
+ pause(): void {
430
+ if (this._isPaused || this._isDestroyed) return;
431
+ this.flushNow();
432
+ this._isPaused = true;
433
+ }
434
+
435
+ /**
436
+ * Thoát dirty-mode. Notify listeners cho đúng các key đã đổi trong lúc paused.
437
+ * Trả về danh sách dirty keys (rỗng = không có gì thay đổi, không render).
438
+ */
439
+ resume(): Array<string | number> {
440
+ if (!this._isPaused || this._isDestroyed) return [];
441
+ this._isPaused = false;
442
+
443
+ const dirty = Array.from(this.dirtyKeys);
444
+ this.dirtyKeys.clear();
445
+
446
+ if (dirty.length > 0) {
447
+ for (const key of dirty) this.pendingChanges.add(key);
448
+ this.flushNow();
282
449
  }
450
+ return dirty;
451
+ }
283
452
 
284
- if (callback) {
285
- const listeners = this.listeners.get(key);
286
- if (listeners) {
287
- const idx = listeners.indexOf(callback);
288
- if (idx !== -1) {
289
- listeners.splice(idx, 1);
290
- if (listeners.length === 0) this.listeners.delete(key);
291
- }
292
- }
293
- } else {
294
- this.listeners.delete(key);
453
+ /** Flush đồng bộ pending changes (huỷ RAF đang chờ nếu có). */
454
+ flushNow(): void {
455
+ if (this.flushRAF !== null) {
456
+ cancelAnimationFrame(this.flushRAF);
457
+ this.flushRAF = null;
295
458
  }
459
+ if (this.pendingChanges.size > 0) {
460
+ this.executeFlush();
461
+ }
462
+ this.hasPendingFlush = false;
296
463
  }
297
464
 
298
465
  // ─── Batch Flush System ─────────────────────────────────────
299
466
 
300
- private commitStateChange(key: string | number, _oldValue: any): void {
467
+ private commitStateChange(key: string | number, _oldValue: any, fromSetter = false): void {
301
468
  if (this._isDestroyed) return;
302
469
  const newValue = this.states[key]?.value;
303
- if (_oldValue === newValue) return;
470
+ if (_oldValue === newValue) {
471
+ if (fromSetter && !this.mutationDiagnostics && newValue !== null
472
+ && typeof newValue === 'object' && !Object.isFrozen(newValue)) {
473
+ // An explicit same-reference setter declares a mutation. No copy
474
+ // is needed in production, including for new keys/array indexes.
475
+ this.enqueueChange(key);
476
+ return;
477
+ }
478
+ // Cùng reference. Trước đây dừng luôn ⇒ `list.splice(i,1); setList(list)`
479
+ // — cách viết TỰ NHIÊN NHẤT — im lặng không cập nhật gì.
480
+ //
481
+ // Khi dev GỌI SETTER là họ đã tuyên bố ý định "giá trị này vừa đổi".
482
+ // Lúc đó ta đối chiếu NỘI DUNG với bản chụp nông của lần flush trước
483
+ // (`mutationSnapshots` — đã có sẵn cho phần cảnh báo) thay vì chỉ so
484
+ // reference. Khác nội dung ⇒ coi như đổi thật.
485
+ //
486
+ // ĐÂY KHÔNG PHẢI deep reactivity/Proxy (xem GAPS §2.16b — quyết định
487
+ // KHÔNG làm vẫn giữ nguyên): không có dep tracking runtime, không đổi
488
+ // granularity. Vẫn đúng một `enqueueChange(key)` ở tầng key — kết quả
489
+ // y hệt như dev tự viết `state.x = [...state.x]`, chỉ khác là không
490
+ // bắt họ phải nhớ.
491
+ if (fromSetter && this.mutatedInPlace(key, newValue)) {
492
+ this.enqueueChange(key);
493
+ return;
494
+ }
495
+ // Đã có cập nhật ĐANG CHỜ cho key này (thường do hook mutate mảng
496
+ // vừa bắt `push`/`splice` và đã làm mới bản chụp) ⇒ set lại cùng ref
497
+ // chỉ là thừa, KHÔNG phải bug. Không cảnh báo, tránh dương tính giả
498
+ // cho cách viết rất phổ biến: `list.splice(i,1); setList(list)`.
499
+ if (fromSetter && (this.pendingChanges.has(key) || this.dirtyKeys.has(key))) return;
500
+ if (fromSetter && this.mutationDiagnostics && !Object.isFrozen(newValue)) this.warnSameReference(key, newValue);
501
+ return;
502
+ }
503
+ this.enqueueChange(key);
504
+ }
505
+
506
+ /**
507
+ * Cùng reference nhưng NỘI DUNG (độ sâu 1) đã khác bản chụp gần nhất?
508
+ *
509
+ * Chưa có bản chụp (state chưa qua flush nào) → trả true: thà render thừa
510
+ * một lần còn hơn nuốt mất cập nhật.
511
+ * Cập nhật lại bản chụp NGAY để hai lần mutate liên tiếp trong cùng một tick
512
+ * đều được nhận, không phải đợi flush làm mới.
513
+ */
514
+ private mutatedInPlace(key: string | number, value: any): boolean {
515
+ if (value === null || typeof value !== 'object') return false;
516
+ const snap = this.mutationSnapshots.get(key);
517
+ const changed = !snap || snap.ref !== value
518
+ || StateManager.shallowDiffers(snap.copy, value);
519
+ if (changed) {
520
+ this.mutationSnapshots.set(key, { ref: value, copy: StateManager.shallowCopy(value) });
521
+ }
522
+ return changed;
523
+ }
524
+
525
+ /** Key đã cảnh báo rồi — mỗi key tối đa 1 dòng cho cả vòng đời app. */
526
+ private static warnedKeys = new Set<string>();
527
+
528
+ /**
529
+ * Đến được đây nghĩa là: dev gọi setter, cùng reference, VÀ nội dung ở độ
530
+ * sâu 1 KHÔNG khác gì (`mutatedInPlace` đã trả false). Còn đúng hai khả năng:
531
+ * - no-op thật (set lại y nguyên) — vô hại;
532
+ * - mutate LỒNG SÂU (`user.profile.name = 'x'`) — `shallowDiffers` chỉ so
533
+ * độ sâu 1 nên không thấy. Đây mới là ca cần cảnh báo.
534
+ * Mutate nông (`push`/`splice`/gán phần tử) rồi set KHÔNG còn tới đây nữa —
535
+ * nó đã được `mutatedInPlace` nhận và cập nhật bình thường.
536
+ *
537
+ * Hai lớp lọc để không có dương tính giả:
538
+ * - chỉ object/array (set lại cùng số/chuỗi là bình thường, vô hại)
539
+ * - chỉ đường `setValue` (dev tự set). Đường `updateStateByKey` —
540
+ * `update$x()` lúc init và `__UPDATE_DATA_TRAIT__` khi cha truyền
541
+ * props — hoàn toàn có thể re-pass đúng ref cũ một cách hợp lệ.
542
+ * Kèm warn-once theo view+key để không spam.
543
+ */
544
+ private warnSameReference(key: string | number, value: any): void {
545
+ if (value === null || typeof value !== 'object') return;
546
+ const path = (this.controller as any)?.path ?? '';
547
+ const warnKey = `${path}::${String(key)}`;
548
+ if (StateManager.warnedKeys.has(warnKey)) return;
549
+ StateManager.warnedKeys.add(warnKey);
550
+ console.warn(
551
+ `[ViewState] "${String(key)}"${path ? ` (view "${path}")` : ''} được set bằng CHÍNH ` +
552
+ `reference cũ và nội dung ở cấp 1 KHÔNG đổi → không có gì cập nhật. ` +
553
+ `Nếu vừa sửa dữ liệu LỒNG SÂU (vd state.${String(key)}.a.b = ...), hãy tạo ` +
554
+ `object/array MỚI ở cấp ngoài: state.${String(key)} = { ...cũ, a: { ...cũ.a, b } }.`
555
+ );
556
+ }
557
+
558
+ /**
559
+ * Đưa key vào hàng đợi flush, KHÔNG so sánh giá trị.
560
+ * Tách khỏi commitStateChange để computed dùng được: so sánh sẽ phải ĐỌC
561
+ * `states[key].value` → kích hoạt tính lại ngay, mất tính lazy.
562
+ */
563
+ private enqueueChange(key: string | number): void {
564
+ if (this._isDestroyed) return;
565
+ this.versions.set(String(key), (this.versions.get(String(key)) ?? 0) + 1);
566
+ this.invalidateComputed(key);
567
+
568
+ // Paused → ghi sổ, không notify (giá trị đã được set vào states)
569
+ if (this._isPaused) {
570
+ this.dirtyKeys.add(key);
571
+ return;
572
+ }
304
573
 
305
574
  this.pendingChanges.add(key);
306
575
 
@@ -310,57 +579,498 @@ export class StateManager implements StateManagerInterface {
310
579
  }
311
580
  }
312
581
 
582
+ /** Trần số vòng flush nối tiếp trong 1 frame — chặn computed phụ thuộc vòng. */
583
+ private static readonly MAX_CASCADE = 20;
584
+
313
585
  private executeFlush(): void {
314
586
  if (this._isDestroyed || this.isFlushing) return;
315
587
  try {
316
588
  this.isFlushing = true;
317
- this.flushChanges();
589
+ // Listener CÓ THỂ enqueue key mới ngay trong lúc flush (computed
590
+ // phụ thuộc computed). flushChanges() đã snapshot xong nên key mới
591
+ // sẽ nằm lại hàng đợi; lặp cho tới khi lắng, trong CÙNG frame —
592
+ // nếu không, cập nhật dẫn xuất kẹt tới lần state đổi kế tiếp.
593
+ let depth = 0;
594
+ while (this.pendingChanges.size > 0 && depth < StateManager.MAX_CASCADE) {
595
+ this.flushChanges();
596
+ depth++;
597
+ }
598
+ if (this.pendingChanges.size > 0) {
599
+ console.warn('[ViewState] Cascade update chưa lắng sau '
600
+ + `${StateManager.MAX_CASCADE} vòng — nghi computed phụ thuộc vòng:`,
601
+ Array.from(this.pendingChanges));
602
+ this.pendingChanges.clear();
603
+ }
318
604
  } finally {
319
605
  this.isFlushing = false;
320
606
  this.hasPendingFlush = false;
321
607
  this.flushRAF = null;
322
608
  }
609
+
610
+ // FIX(F6, docs/FIX_PLAN_2026-08-14.md): state flush chạy trên RAF #1;
611
+ // listener gọi Reactive.update() → ctx.scheduleUpdate() lại đặt RAF #2
612
+ // (ViewController.scheduleUpdate) — trong một frame, Output patch
613
+ // textContent ĐỒNG BỘ ngay trong listener nên đã đổi, còn
614
+ // @foreach/@if (chờ RAF #2) thì CHƯA — DOM tạm thời không nhất quán
615
+ // (đo được: `{{ count }}` đã là "1" nhưng @foreach vẫn list cũ, phải
616
+ // đợi thêm 1 frame nữa). Flush luôn ngay sau khi cascade lắng, CÙNG
617
+ // frame — không đợi RAF kế tiếp. RAF trong scheduleUpdate() vẫn giữ
618
+ // nguyên làm đường dự phòng cho update phát sinh NGOÀI chu kỳ flush
619
+ // state; gặp hàng đợi rỗng (đã flush ở đây) thì chỉ là no-op.
620
+ this.controller?.flushReactiveUpdatesNow?.();
621
+ }
622
+
623
+ // ─── Phát hiện mutate tại chỗ KHÔNG kèm set ──────────────────
624
+ // `warnSameReference` chỉ bắt được `list.push(x); setList(list)` — có đi qua
625
+ // setter. Trường hợp còn lại KHÔNG đi qua đâu cả:
626
+ // list.push(x); // hết. Không cập nhật, không cảnh báo.
627
+ // Chỗ duy nhất còn quan sát được là lúc flush: so snapshot NÔNG của lần
628
+ // flush trước với giá trị hiện tại. Reference y nguyên mà nội dung đã khác
629
+ // ⇒ ai đó mutate ngoài luồng.
630
+
631
+ /** Bản sao nông của lần flush gần nhất, theo key. */
632
+ private mutationSnapshots = new Map<string | number, { ref: any; copy: any }>();
633
+
634
+ // ─── Tự bắt mutate TẠI CHỖ (mảng và object) ─────────────────
635
+ //
636
+ // `list.push(x)` / `user.name = 'x'` KHÔNG đổi reference và KHÔNG đi qua
637
+ // setter nào ⇒ trước đây chỉ được PHÁT HIỆN muộn ở `detectExternalMutation`
638
+ // (cảnh báo, không cập nhật). Cách vá — kỹ thuật **Vue 2**, KHÔNG phải Proxy:
639
+ // - MẢNG: thay các method mutate ngay trên chính mảng đó bằng bản bọc;
640
+ // - OBJECT: thay từng own-property bằng cặp getter/setter.
641
+ // Gọi/gán xong thì `enqueueChange(key)`.
642
+ //
643
+ // Vì sao hợp kiến trúc này:
644
+ // - reference KHÔNG đổi ⇒ `ForeachSlotCache` (so `slot.item === item`),
645
+ // `Array.isArray`, `===` của người dùng đều nguyên vẹn;
646
+ // - method vá của mảng là own-property KHÔNG enumerable; accessor của
647
+ // object giữ `enumerable: true` ⇒ spread / `JSON.stringify` /
648
+ // `Object.keys` / `for…in` đều không thấy gì khác;
649
+ // - vẫn đúng MỘT `enqueueChange` ở TẦNG KEY GỐC ⇒ granularity không đổi,
650
+ // không cần dep tracking runtime (giữ nguyên lập luận GAPS §2.16b),
651
+ // `stateKeys`/contract SSR không đụng tới.
652
+ //
653
+ // Quan sát ĐỆ QUY: `user.profile.name = 'x'` (lồng sâu) trước đây vừa không
654
+ // cập nhật vừa KHÔNG cảnh báo (`shallowDiffers` chỉ so độ sâu 1) — im lặng
655
+ // hoàn toàn, ca tệ nhất. Nay bắt được.
656
+ //
657
+ // GIỚI HẠN (cần Proxy mới vượt, giống hệt Vue 2):
658
+ // - THÊM KEY MỚI chưa từng có lúc quan sát: `user.extra = 1`;
659
+ // - gán qua index/length của mảng: `list[0] = x`, `list.length = 0`.
660
+ // Hai ca này vẫn do `detectExternalMutation` cảnh báo ở lần flush kế.
661
+
662
+ private static readonly ARRAY_MUTATORS = [
663
+ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin',
664
+ ] as const;
665
+
666
+ /** Tập "kênh" gắn trên một node — nhiều view/key có thể dùng chung dữ liệu. */
667
+ private static readonly HOOKS = Symbol.for('sao.mutationHooks');
668
+
669
+ /**
670
+ * Một kênh cho MỘT key. `nodes` giữ đúng tập node đang reachable để cleanup
671
+ * không phụ thuộc vào hình dạng cây tại thời điểm destroy.
672
+ */
673
+ private trackedChannels = new Map<string | number, MutationChannel>();
674
+
675
+ /**
676
+ * Key vừa được hook mutate xử lý trong chu kỳ hiện tại — `detectExternalMutation`
677
+ * dựa vào đây để chụp lại mà KHÔNG cảnh báo. Xoá sau mỗi lần quét.
678
+ */
679
+ private hookHandledKeys = new Set<string | number>();
680
+
681
+ /** Chỉ quan sát object THUẦN và mảng — tránh phá Date/Map/Set/instance class. */
682
+ private static isObservable(v: any): boolean {
683
+ if (v === null || typeof v !== 'object' || Object.isFrozen(v)) return false;
684
+ if (Array.isArray(v)) return true;
685
+ const proto = Object.getPrototypeOf(v);
686
+ return proto === Object.prototype || proto === null;
687
+ }
688
+
689
+ /**
690
+ * Gắn (hoặc gỡ) bộ bắt mutate cho giá trị mới của `key`.
691
+ * Gọi ở MỌI chỗ gán `states[key].value`.
692
+ */
693
+ private trackArray(key: string | number, value: any): void {
694
+ if (this.trackedChannels.get(key)?.root === value) return;
695
+ this.untrackKey(key);
696
+ if (!StateManager.isObservable(value)) return;
697
+
698
+ const channel: MutationChannel = { notify: null, root: value, nodes: new Set() };
699
+ channel.notify = () => {
700
+ if (this._isDestroyed) return;
701
+ // ĐƯỜNG NÓNG — chạy mỗi lần mutate. Chỉ ĐÁNH DẤU (O(1)); tuyệt đối
702
+ // không chụp lại ở đây. Bản trước gọi `shallowCopy(value)` tại chỗ
703
+ // này ⇒ mỗi lần gán một thuộc tính lại copy CẢ mảng: đo được 1000
704
+ // lần gán trên list 10k mất ~28ms. `detectExternalMutation` ở đầu
705
+ // flush vốn đã chụp lại mọi key rồi — chỉ cần cho nó biết key này
706
+ // đã được xử lý để đừng cảnh báo "mutate mà KHÔNG set lại".
707
+ if (this.mutationDiagnostics) this.hookHandledKeys.add(key);
708
+ this.enqueueChange(key);
709
+ };
710
+ this.trackedChannels.set(key, channel);
711
+ StateManager.observe(value, channel, new Set());
712
+ }
713
+
714
+ /**
715
+ * Ngừng theo dõi một key: GỠ HẲN channel khỏi tập hook của từng node.
716
+ *
717
+ * Chỉ `notify = null` là KHÔNG đủ. Dữ liệu ở đây được truyền bằng THAM CHIẾU
718
+ * TRỰC TIẾP (item của `@foreach` đi thẳng vào view con qua props, mảng có thể
719
+ * nằm trong store dùng chung), nên cùng một object sống qua nhiều lần
720
+ * mount/destroy. Channel chết mà nằm lại thì tập hook phình vô hạn — đo được:
721
+ * 50 lần mount/destroy trên cùng mảng để lại 50 channel. Đúng lớp lỗi mà
722
+ * `tests/view/registry-cleanup.test.ts` canh ("mọi registry phải có trần").
723
+ *
724
+ * Channel giữ tập node đang sống. Mỗi mutator/setter gỡ node tách khỏi cây
725
+ * ngay lập tức; nhờ vậy destroy có thể dọn cả cây chính xác mà không bỏ sót
726
+ * object đã từng được observe.
727
+ */
728
+ private untrackKey(key: string | number): void {
729
+ const prev = this.trackedChannels.get(key);
730
+ if (!prev) return;
731
+ prev.notify = null; // chặn notify ngay
732
+ for (const node of prev.nodes) {
733
+ const hooks: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
734
+ hooks?.delete(prev);
735
+ }
736
+ prev.nodes.clear();
737
+ this.trackedChannels.delete(key);
738
+ }
739
+
740
+ /** Gỡ mọi channel của manager này (destroy view). */
741
+ private untrackAllArrays(): void {
742
+ for (const key of Array.from(this.trackedChannels.keys())) this.untrackKey(key);
743
+ this.trackedChannels.clear();
744
+ }
745
+
746
+ /** Gỡ `channel` khỏi tập hook của `node` và toàn bộ cây con. */
747
+ private static unobserve(node: any, channel: MutationChannel, seen: Set<any>, reachable?: Set<any>): void {
748
+ if (!StateManager.isObservable(node) || seen.has(node)) return;
749
+ seen.add(node);
750
+ if (!reachable?.has(node)) {
751
+ const hooks: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
752
+ hooks?.delete(channel);
753
+ channel.nodes.delete(node);
754
+ }
755
+ if (Array.isArray(node)) {
756
+ for (const item of node) StateManager.unobserve(item, channel, seen, reachable);
757
+ return;
758
+ }
759
+ for (const prop of Object.keys(node)) StateManager.unobserve(node[prop], channel, seen, reachable);
760
+ }
761
+
762
+ /** Thu thập cây hiện còn reachable để không gỡ nhầm shared reference. */
763
+ private static collectReachable(node: any, seen = new Set<any>()): Set<any> {
764
+ if (!StateManager.isObservable(node) || seen.has(node)) return seen;
765
+ seen.add(node);
766
+ if (Array.isArray(node)) {
767
+ for (const item of node) StateManager.collectReachable(item, seen);
768
+ } else {
769
+ for (const prop of Object.keys(node)) StateManager.collectReachable(node[prop], seen);
770
+ }
771
+ return seen;
772
+ }
773
+
774
+ /**
775
+ * Cài bộ bắt mutate lên `node` và toàn bộ cây con, rồi ghi `channel` vào
776
+ * tập hook của mỗi node. `seen` chặn vòng lặp tham chiếu.
777
+ */
778
+ private static observe(node: any, channel: MutationChannel, seen: Set<any>): void {
779
+ if (!StateManager.isObservable(node) || seen.has(node)) return;
780
+ seen.add(node);
781
+
782
+ let hooks: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
783
+ const fresh = !hooks;
784
+ if (!hooks) {
785
+ hooks = new Set();
786
+ try {
787
+ Object.defineProperty(node, StateManager.HOOKS, {
788
+ value: hooks, enumerable: false, configurable: true, writable: false,
789
+ });
790
+ } catch {
791
+ return; // node bị seal → bỏ qua, vẫn còn cảnh báo lúc flush
792
+ }
793
+ }
794
+ hooks!.add(channel);
795
+ channel.nodes.add(node);
796
+
797
+ // Đường NÓNG — chạy mỗi lần gán/mutate. Duyệt thẳng, không `Array.from`:
798
+ // `notify` chỉ enqueue key, không bao giờ thêm/bớt hook nên không có
799
+ // rủi ro sửa Set đang duyệt.
800
+ const fire = () => {
801
+ const set: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
802
+ if (!set) return;
803
+ for (const ch of set) ch.notify?.();
804
+ };
805
+
806
+ /** Giá trị MỚI gán vào cũng phải được quan sát — chỉ khi nó đáng quan sát. */
807
+ const observeIncoming = (next: any) => {
808
+ if (!StateManager.isObservable(next)) return; // primitive: thoát sớm, không cấp phát
809
+ const set: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
810
+ if (!set) return;
811
+ for (const ch of Array.from(set)) {
812
+ if (ch.notify) StateManager.observe(next, ch, new Set());
813
+ }
814
+ };
815
+
816
+ /** Gỡ hook khỏi nhánh vừa rời cây, nhưng giữ node còn reachable qua nhánh khác. */
817
+ const unobserveOutgoing = (outgoing: any[]) => {
818
+ const observableOutgoing = outgoing.filter(StateManager.isObservable);
819
+ if (observableOutgoing.length === 0) return;
820
+ const set: Set<MutationChannel> | undefined = node[StateManager.HOOKS];
821
+ if (!set) return;
822
+ for (const ch of Array.from(set)) {
823
+ if (!ch.notify) continue;
824
+ const reachable = StateManager.collectReachable(ch.root);
825
+ const seen = new Set<any>();
826
+ for (const oldValue of observableOutgoing) {
827
+ StateManager.unobserve(oldValue, ch, seen, reachable);
828
+ }
829
+ }
830
+ };
831
+
832
+ if (Array.isArray(node)) {
833
+ if (fresh) {
834
+ for (const name of StateManager.ARRAY_MUTATORS) {
835
+ const original = (Array.prototype as any)[name];
836
+ try {
837
+ Object.defineProperty(node, name, {
838
+ value: function (this: any[], ...args: any[]) {
839
+ const outgoing: any[] = [];
840
+ const length = this.length;
841
+ const normalizeIndex = (raw: any, fallback: number) => {
842
+ const number = raw === undefined ? fallback : Number(raw);
843
+ if (!Number.isFinite(number)) return number < 0 ? 0 : length;
844
+ const integer = Math.trunc(number);
845
+ return integer < 0
846
+ ? Math.max(length + integer, 0)
847
+ : Math.min(integer, length);
848
+ };
849
+
850
+ if (name === 'fill') {
851
+ outgoing.push(...this.slice(
852
+ normalizeIndex(args[1], 0),
853
+ normalizeIndex(args[2], length),
854
+ ));
855
+ } else if (name === 'copyWithin') {
856
+ const target = normalizeIndex(args[0], 0);
857
+ const start = normalizeIndex(args[1], 0);
858
+ const end = normalizeIndex(args[2], length);
859
+ const count = Math.max(0, Math.min(end - start, length - target));
860
+ outgoing.push(...this.slice(target, target + count));
861
+ }
862
+
863
+ const result = original.apply(this, args);
864
+ if ((name === 'pop' || name === 'shift') && result !== undefined) {
865
+ outgoing.push(result);
866
+ } else if (name === 'splice') {
867
+ outgoing.push(...result);
868
+ }
869
+ // Chỉ quan sát phần tử MỚI ĐƯA VÀO (tham số), KHÔNG
870
+ // quét lại cả mảng — quét lại là O(n) mỗi lần push,
871
+ // list 10k phần tử sẽ đứng hình. `push`/`unshift`/
872
+ // `splice`/`fill` truyền giá trị mới qua args; các
873
+ // method còn lại chỉ nhận số/hàm nên `isObservable`
874
+ // tự bỏ qua.
875
+ for (const arg of args) observeIncoming(arg);
876
+ unobserveOutgoing(outgoing);
877
+ fire();
878
+ return result;
879
+ },
880
+ enumerable: false, configurable: true, writable: true,
881
+ });
882
+ } catch { /* method không ghi đè được → bỏ qua */ }
883
+ }
884
+ }
885
+ for (const item of node) StateManager.observe(item, channel, seen);
886
+ return;
887
+ }
888
+
889
+ for (const prop of Object.keys(node)) {
890
+ StateManager.observe(node[prop], channel, seen);
891
+ if (!fresh) continue; // accessor đã cài từ lần trước
892
+ const desc = Object.getOwnPropertyDescriptor(node, prop);
893
+ if (!desc || !desc.configurable || desc.get || desc.set) continue;
894
+ let current = desc.value;
895
+ try {
896
+ Object.defineProperty(node, prop, {
897
+ enumerable: desc.enumerable, // giữ nguyên → spread/keys không đổi
898
+ configurable: true,
899
+ get: () => current,
900
+ set: (next) => {
901
+ if (next === current) return;
902
+ const previous = current;
903
+ current = next;
904
+ observeIncoming(next);
905
+ unobserveOutgoing([previous]);
906
+ fire();
907
+ },
908
+ });
909
+ } catch { /* prop không cấu hình được → bỏ qua */ }
910
+ }
911
+ }
912
+
913
+ private static shallowCopy(v: any): any {
914
+ return Array.isArray(v) ? v.slice() : { ...v };
915
+ }
916
+
917
+ /**
918
+ * ponytail: chỉ so ĐỘ SÂU 1 — bắt push/splice/shift/sort/gán lại phần tử/
919
+ * thêm-bớt field. KHÔNG bắt `user.profile.name = 'x'`. So sâu cần deep clone
920
+ * mỗi flush; nếu mutate lồng thành vấn đề thật thì đó là lúc cân nhắc Proxy,
921
+ * không phải làm snapshot nặng thêm.
922
+ */
923
+ private static shallowDiffers(prev: any, cur: any): boolean {
924
+ if (Array.isArray(cur)) {
925
+ if (!Array.isArray(prev) || prev.length !== cur.length) return true;
926
+ for (let i = 0; i < cur.length; i++) if (prev[i] !== cur[i]) return true;
927
+ return false;
928
+ }
929
+ if (Array.isArray(prev)) return true;
930
+ const prevKeys = Object.keys(prev);
931
+ const curKeys = Object.keys(cur);
932
+ if (prevKeys.length !== curKeys.length) return true;
933
+ for (const k of curKeys) if (prev[k] !== cur[k]) return true;
934
+ return false;
935
+ }
936
+
937
+ /**
938
+ * Chạy đầu mỗi flush: mọi state kiểu object được đối chiếu rồi chụp lại.
939
+ * Nghĩa là mutate lặng lẽ sẽ lộ ở lần flush KẾ TIẾP do bất kỳ key nào —
940
+ * gần như luôn xảy ra ngay lần tương tác sau.
941
+ */
942
+ private detectExternalMutation(): void {
943
+ if (!this.mutationDiagnostics) return;
944
+ for (const key in this.states) {
945
+ const slot: any = this.states[key];
946
+ if (slot?.__computed__) continue; // lazy — đọc `.value` sẽ ép tính lại
947
+ const value = slot?.value;
948
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
949
+ this.mutationSnapshots.delete(key);
950
+ continue;
951
+ }
952
+ const snap = this.mutationSnapshots.get(key);
953
+ if (snap && snap.ref === value
954
+ && !this.hookHandledKeys.has(key) // hook đã lo → không phải "quên set"
955
+ && StateManager.shallowDiffers(snap.copy, value)) {
956
+ this.warnMutatedWithoutSet(key);
957
+ }
958
+ this.mutationSnapshots.set(key, { ref: value, copy: StateManager.shallowCopy(value) });
959
+ }
960
+ this.hookHandledKeys.clear();
961
+ }
962
+
963
+ /** Dùng CHUNG `warnedKeys` với warnSameReference — 1 key chỉ kêu 1 lần. */
964
+ private warnMutatedWithoutSet(key: string | number): void {
965
+ const path = (this.controller as any)?.path ?? '';
966
+ const warnKey = `${path}::${String(key)}`;
967
+ if (StateManager.warnedKeys.has(warnKey)) return;
968
+ StateManager.warnedKeys.add(warnKey);
969
+ console.warn(
970
+ `[ViewState] "${String(key)}"${path ? ` (view "${path}")` : ''} bị thay đổi tại chỗ ` +
971
+ `mà KHÔNG set lại → UI không cập nhật. Thay vì mutate, hãy gán giá trị mới: ` +
972
+ `state.${String(key)} = [...] / { ... }.`
973
+ );
323
974
  }
324
975
 
325
976
  private flushChanges(): void {
326
977
  if (this.pendingChanges.size === 0) return;
327
978
 
979
+ this.detectExternalMutation();
980
+
328
981
  const changed = Array.from(this.pendingChanges);
329
982
  this.pendingChanges.clear();
330
983
 
331
- // Reset multi-key listener flags
332
- for (const listener of this.multiKeyListeners) {
333
- listener.called = false;
984
+ if (devtools.isEnabled()) {
985
+ devtools.emit('state:changed', {
986
+ viewId: (this.controller as any)?.viewId,
987
+ path: (this.controller as any)?.path,
988
+ detail: { keys: changed.map(String) },
989
+ });
334
990
  }
335
991
 
336
- // Notify single-key listeners
337
- for (const changedKey of changed) {
338
- const listeners = this.listeners.get(changedKey);
339
- if (listeners) {
340
- const currentValue = this.states[changedKey]?.value;
341
- for (const listener of listeners) {
342
- try { listener(currentValue); }
343
- catch (e) { console.error('[ViewState] Listener error:', e); }
344
- }
345
- }
346
-
347
- // Notify multi-key listeners
348
- for (const mkl of this.multiKeyListeners) {
349
- if (!mkl.called && mkl.keys.has(changedKey)) {
350
- mkl.called = true;
351
- const values: Record<string, any> = {};
352
- for (const k of mkl.keys) {
353
- if (changed.includes(k as any)) {
354
- values[String(k)] = this.states[k]?.value;
992
+ // Snapshot the whole batch before invoking callbacks. Subscriptions added
993
+ // during notification start next batch; removed records are never invoked.
994
+ const changedSet = new Set(changed.map(String));
995
+ const batches = changed.map(key => [String(key), Array.from(this.subscriptions.get(String(key)) ?? [])] as const);
996
+ const called = new Set<Subscription>();
997
+ for (const [key, records] of batches) {
998
+ // Retain single-key-before-multi-key order used by existing bindings.
999
+ for (const multi of [false, true]) {
1000
+ for (const record of records) {
1001
+ if (!record.active || record.multi !== multi || called.has(record)) continue;
1002
+ called.add(record);
1003
+ let value: any;
1004
+ if (multi) {
1005
+ value = {};
1006
+ for (const k of record.keys) {
1007
+ if (changedSet.has(k)) value[k] = this.states[k]?.value;
355
1008
  }
1009
+ } else value = this.states[key]?.value;
1010
+ try { record.callback(value); }
1011
+ catch (e) {
1012
+ this.retryAfterReactiveFlush(e, v => {
1013
+ if (record.active) record.callback(v);
1014
+ }, value);
356
1015
  }
357
- try { mkl.callback(values); }
358
- catch (e) { console.error('[ViewState] Multi-key listener error:', e); }
359
1016
  }
360
1017
  }
361
1018
  }
362
1019
  }
363
1020
 
1021
+ /**
1022
+ * Vùng reactive re-render ở RAF kế tiếp, còn listener của Output/Text/binding
1023
+ * chạy NGAY trong đợt flush này. Nên khi guard đổi chiều
1024
+ * (`@if(record !== null)` thành false), factory bên trong vùng —
1025
+ * `{{ record['name'] }}` — vẫn chạy một lần với state mới và ném, dù DOM đó
1026
+ * chỉ còn sống thêm một frame.
1027
+ *
1028
+ * Có vùng đang chờ dựng lại thì chạy lại listener SAU đợt đó: element hoặc
1029
+ * đã bị destroy (listener no-op), hoặc chạy được với state nhất quán. Còn
1030
+ * ném nữa mới là lỗi thật và đi tiếp tới boundary.
1031
+ *
1032
+ * rAF của flushReactiveUpdates đã đăng ký TRƯỚC (trong chính đợt flush này,
1033
+ * lúc vùng gọi scheduleUpdate), nên rAF đăng ký ở đây chạy sau nó.
1034
+ *
1035
+ * Giới hạn: chỉ bắt được khi vùng bọc đã kịp vào hàng đợi trước listener
1036
+ * này — đúng với @if/@foreach bọc ngoài vì chúng subscribe trước con.
1037
+ */
1038
+ private retryAfterReactiveFlush(err: unknown, listener: (value: any) => void, value: any): void {
1039
+ const ctrl: any = this.controller;
1040
+ if (!ctrl?.hasPendingReactiveUpdate?.()) {
1041
+ this.reportListenerError(err);
1042
+ return;
1043
+ }
1044
+ requestAnimationFrame(() => {
1045
+ if (this._isDestroyed) return;
1046
+ try { listener(value); }
1047
+ catch (e) { this.reportListenerError(e); }
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Lỗi ném ra từ callback subscribe — đưa về error boundary thay vì nuốt.
1053
+ *
1054
+ * MỌI factory người dùng chạy khi state đổi đều đi qua đây: Output `{{ }}`,
1055
+ * TextElement, Html attr/class/style/prop binding, mirror-sync của computed.
1056
+ * Trước đây chỉ `console.error` → DOM giữ giá trị cũ và boundary KHÔNG hề
1057
+ * biết (im lặng sai, tệ hơn nổ). Đây là 1 chỗ bao trọn tất cả các đường đó.
1058
+ *
1059
+ * Không có "fallback content" ở tầng này (không biết vùng DOM nào hỏng) —
1060
+ * boundary chỉ được BÁO để log/đặt state lỗi; giá trị trả về bị bỏ qua.
1061
+ * Muốn thay nội dung vùng lỗi thì dùng boundary ở Component/Reactive
1062
+ * (phase 'render'/'update'), nơi có ranh giới marker rõ ràng.
1063
+ */
1064
+ private reportListenerError(err: unknown): void {
1065
+ const ctrl: any = this.controller;
1066
+ try {
1067
+ if (ctrl?.handleError?.(err, { phase: 'update', path: ctrl.path ?? '' })?.handled) return;
1068
+ } catch (e) {
1069
+ console.error('[ViewState] onError handler threw:', e);
1070
+ }
1071
+ console.error('[ViewState] Listener error:', err);
1072
+ }
1073
+
364
1074
  // ─── Cleanup ────────────────────────────────────────────────
365
1075
 
366
1076
  destroy(): void {
@@ -369,9 +1079,17 @@ export class StateManager implements StateManagerInterface {
369
1079
  cancelAnimationFrame(this.flushRAF);
370
1080
  this.flushRAF = null;
371
1081
  }
372
- this.listeners.clear();
373
- this.multiKeyListeners = [];
1082
+ this.computedNodes.clear();
1083
+ this.computedDependents.clear();
1084
+ this.versions.clear();
1085
+ for (const records of this.subscriptions.values()) {
1086
+ for (const record of records) record.active = false;
1087
+ }
1088
+ this.subscriptions.clear();
374
1089
  this.pendingChanges.clear();
1090
+ this.untrackAllArrays();
1091
+ this.hookHandledKeys.clear();
1092
+ this.mutationSnapshots.clear();
375
1093
  this.states = {};
376
1094
  this.setters = {};
377
1095
  this.controller = null;