@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.
- package/README.md +52 -195
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.js +2 -2
- package/dist/src/core/app/Application.d.ts +17 -15
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +41 -18
- package/dist/src/core/app/Application.js.map +1 -1
- package/dist/src/core/bootstrap/app.d.ts +1 -1
- package/dist/src/core/bootstrap/app.d.ts.map +1 -1
- package/dist/src/core/bootstrap/app.js +74 -4
- package/dist/src/core/bootstrap/app.js.map +1 -1
- package/dist/src/core/bootstrap/bundle.d.ts +41 -0
- package/dist/src/core/bootstrap/bundle.d.ts.map +1 -0
- package/dist/src/core/bootstrap/bundle.js +98 -0
- package/dist/src/core/bootstrap/bundle.js.map +1 -0
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.js +3 -3
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js +11 -8
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts +3 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.js +8 -3
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts +2 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.js +14 -4
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts +4 -4
- package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ServiceProvider.js +5 -5
- package/dist/src/core/bootstrap/providers/ServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.js +3 -3
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js +44 -11
- package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
- package/dist/src/core/bootstrap/providers/index.d.ts +9 -9
- package/dist/src/core/bootstrap/providers/index.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/index.js +8 -8
- package/dist/src/core/bootstrap/providers/index.js.map +1 -1
- package/dist/src/core/bootstrap/providers/provider-order.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/provider-order.d.ts.map +1 -1
- package/dist/src/core/bootstrap/ssr.d.ts +47 -0
- package/dist/src/core/bootstrap/ssr.d.ts.map +1 -0
- package/dist/src/core/bootstrap/ssr.js +125 -0
- package/dist/src/core/bootstrap/ssr.js.map +1 -0
- package/dist/src/core/cache/ViewCache.d.ts +1 -1
- package/dist/src/core/cache/ViewCache.d.ts.map +1 -1
- package/dist/src/core/cache/index.d.ts +1 -1
- package/dist/src/core/cache/index.d.ts.map +1 -1
- package/dist/src/core/cache/index.js +1 -1
- package/dist/src/core/cache/index.js.map +1 -1
- package/dist/src/core/contracts/ApplicationInterface.d.ts +13 -11
- package/dist/src/core/contracts/ApplicationInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/BlockInterface.d.ts +10 -10
- package/dist/src/core/contracts/BlockInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/BundleInterface.d.ts +38 -0
- package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/BundleInterface.js +2 -0
- package/dist/src/core/contracts/BundleInterface.js.map +1 -0
- package/dist/src/core/contracts/ComponentInterface.d.ts +7 -6
- package/dist/src/core/contracts/ComponentInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ElementInterface.d.ts +91 -27
- package/dist/src/core/contracts/ElementInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HeadServiceInterface.d.ts +37 -0
- package/dist/src/core/contracts/HeadServiceInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/HeadServiceInterface.js +3 -0
- package/dist/src/core/contracts/HeadServiceInterface.js.map +1 -0
- package/dist/src/core/contracts/HelperInterface.d.ts +9 -1
- package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HttpServiceInterface.d.ts +3 -0
- package/dist/src/core/contracts/HttpServiceInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/MarkerInterface.d.ts +29 -44
- package/dist/src/core/contracts/MarkerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ReactiveInterface.d.ts +9 -9
- package/dist/src/core/contracts/ReactiveInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/RouterInterface.d.ts +5 -2
- package/dist/src/core/contracts/RouterInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/SectionInterface.d.ts +48 -0
- package/dist/src/core/contracts/SectionInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/SectionInterface.js +2 -0
- package/dist/src/core/contracts/SectionInterface.js.map +1 -0
- package/dist/src/core/contracts/ServiceProviderInterface.d.ts +3 -3
- package/dist/src/core/contracts/ServiceProviderInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewControllerInterface.d.ts +116 -15
- package/dist/src/core/contracts/ViewControllerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewInterface.d.ts +31 -5
- package/dist/src/core/contracts/ViewInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewManagerInterface.d.ts +47 -5
- package/dist/src/core/contracts/ViewManagerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewStateInterface.d.ts +39 -2
- package/dist/src/core/contracts/ViewStateInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/common.d.ts +3 -3
- package/dist/src/core/contracts/common.js +3 -3
- package/dist/src/core/contracts/utils.d.ts +19 -19
- package/dist/src/core/contracts/utils.d.ts.map +1 -1
- package/dist/src/core/contracts/views.d.ts +1 -22
- package/dist/src/core/contracts/views.d.ts.map +1 -1
- package/dist/src/core/devtools/hook.d.ts +66 -0
- package/dist/src/core/devtools/hook.d.ts.map +1 -0
- package/dist/src/core/devtools/hook.js +114 -0
- package/dist/src/core/devtools/hook.js.map +1 -0
- package/dist/src/core/devtools/index.d.ts +34 -0
- package/dist/src/core/devtools/index.d.ts.map +1 -0
- package/dist/src/core/devtools/index.js +33 -0
- package/dist/src/core/devtools/index.js.map +1 -0
- package/dist/src/core/devtools/inspector.d.ts +31 -0
- package/dist/src/core/devtools/inspector.d.ts.map +1 -0
- package/dist/src/core/devtools/inspector.js +249 -0
- package/dist/src/core/devtools/inspector.js.map +1 -0
- package/dist/src/core/elements/Block.d.ts +9 -7
- package/dist/src/core/elements/Block.d.ts.map +1 -1
- package/dist/src/core/elements/Block.js +33 -9
- package/dist/src/core/elements/Block.js.map +1 -1
- package/dist/src/core/elements/BlockOutlet.d.ts +19 -8
- package/dist/src/core/elements/BlockOutlet.d.ts.map +1 -1
- package/dist/src/core/elements/BlockOutlet.js +57 -13
- package/dist/src/core/elements/BlockOutlet.js.map +1 -1
- package/dist/src/core/elements/Component.d.ts +83 -11
- package/dist/src/core/elements/Component.d.ts.map +1 -1
- package/dist/src/core/elements/Component.js +334 -13
- package/dist/src/core/elements/Component.js.map +1 -1
- package/dist/src/core/elements/ForeachSlotCache.d.ts +95 -0
- package/dist/src/core/elements/ForeachSlotCache.d.ts.map +1 -0
- package/dist/src/core/elements/ForeachSlotCache.js +149 -0
- package/dist/src/core/elements/ForeachSlotCache.js.map +1 -0
- package/dist/src/core/elements/Fragment.d.ts +19 -13
- package/dist/src/core/elements/Fragment.d.ts.map +1 -1
- package/dist/src/core/elements/Fragment.js +43 -20
- package/dist/src/core/elements/Fragment.js.map +1 -1
- package/dist/src/core/elements/Html.d.ts +89 -12
- package/dist/src/core/elements/Html.d.ts.map +1 -1
- package/dist/src/core/elements/Html.js +707 -106
- package/dist/src/core/elements/Html.js.map +1 -1
- package/dist/src/core/elements/Output.d.ts +34 -10
- package/dist/src/core/elements/Output.d.ts.map +1 -1
- package/dist/src/core/elements/Output.js +141 -26
- package/dist/src/core/elements/Output.js.map +1 -1
- package/dist/src/core/elements/Reactive.d.ts +85 -14
- package/dist/src/core/elements/Reactive.d.ts.map +1 -1
- package/dist/src/core/elements/Reactive.js +315 -41
- package/dist/src/core/elements/Reactive.js.map +1 -1
- package/dist/src/core/elements/TextElement.d.ts +11 -7
- package/dist/src/core/elements/TextElement.d.ts.map +1 -1
- package/dist/src/core/elements/TextElement.js +18 -11
- package/dist/src/core/elements/TextElement.js.map +1 -1
- package/dist/src/core/elements/Wrapper.d.ts +24 -13
- package/dist/src/core/elements/Wrapper.d.ts.map +1 -1
- package/dist/src/core/elements/Wrapper.js +65 -8
- package/dist/src/core/elements/Wrapper.js.map +1 -1
- package/dist/src/core/elements/Yield.d.ts +10 -9
- package/dist/src/core/elements/Yield.d.ts.map +1 -1
- package/dist/src/core/elements/Yield.js +31 -19
- package/dist/src/core/elements/Yield.js.map +1 -1
- package/dist/src/core/elements/helpers.d.ts +7 -7
- package/dist/src/core/elements/helpers.d.ts.map +1 -1
- package/dist/src/core/elements/helpers.js +4 -4
- package/dist/src/core/elements/index.d.ts +12 -11
- package/dist/src/core/elements/index.d.ts.map +1 -1
- package/dist/src/core/elements/index.js +12 -11
- package/dist/src/core/elements/index.js.map +1 -1
- package/dist/src/core/elements/reconcileChildren.d.ts +7 -0
- package/dist/src/core/elements/reconcileChildren.d.ts.map +1 -0
- package/dist/src/core/elements/reconcileChildren.js +74 -0
- package/dist/src/core/elements/reconcileChildren.js.map +1 -0
- package/dist/src/core/hellpers/utils.d.ts +2 -2
- package/dist/src/core/hellpers/utils.js +4 -4
- package/dist/src/core/helpers/ApiClient.d.ts +58 -0
- package/dist/src/core/helpers/ApiClient.d.ts.map +1 -0
- package/dist/src/core/helpers/ApiClient.js +131 -0
- package/dist/src/core/helpers/ApiClient.js.map +1 -0
- package/dist/src/core/helpers/app.d.ts +3 -0
- package/dist/src/core/helpers/app.d.ts.map +1 -0
- package/dist/src/core/helpers/app.js +63 -0
- package/dist/src/core/helpers/app.js.map +1 -0
- package/dist/src/core/helpers/transition.d.ts +34 -0
- package/dist/src/core/helpers/transition.d.ts.map +1 -0
- package/dist/src/core/helpers/transition.js +118 -0
- package/dist/src/core/helpers/transition.js.map +1 -0
- package/dist/src/core/helpers/utils.d.ts +26 -0
- package/dist/src/core/helpers/utils.d.ts.map +1 -0
- package/dist/src/core/helpers/utils.js +96 -0
- package/dist/src/core/helpers/utils.js.map +1 -0
- package/dist/src/core/helpers/view.d.ts +39 -0
- package/dist/src/core/helpers/view.d.ts.map +1 -0
- package/dist/src/core/helpers/view.js +286 -0
- package/dist/src/core/helpers/view.js.map +1 -0
- package/dist/src/core/routers/Router.d.ts +108 -7
- package/dist/src/core/routers/Router.d.ts.map +1 -1
- package/dist/src/core/routers/Router.js +444 -68
- package/dist/src/core/routers/Router.js.map +1 -1
- package/dist/src/core/routers/index.d.ts +2 -2
- package/dist/src/core/routers/index.d.ts.map +1 -1
- package/dist/src/core/routers/index.js +1 -1
- package/dist/src/core/routers/index.js.map +1 -1
- package/dist/src/core/services/AssetManager.d.ts +112 -0
- package/dist/src/core/services/AssetManager.d.ts.map +1 -0
- package/dist/src/core/services/AssetManager.js +361 -0
- package/dist/src/core/services/AssetManager.js.map +1 -0
- package/dist/src/core/services/BlockManager.d.ts +73 -2
- package/dist/src/core/services/BlockManager.d.ts.map +1 -1
- package/dist/src/core/services/BlockManager.js +270 -48
- package/dist/src/core/services/BlockManager.js.map +1 -1
- package/dist/src/core/services/DomService.js +3 -3
- package/dist/src/core/services/DomService.js.map +1 -1
- package/dist/src/core/services/HeadService.d.ts +24 -0
- package/dist/src/core/services/HeadService.d.ts.map +1 -0
- package/dist/src/core/services/HeadService.js +120 -0
- package/dist/src/core/services/HeadService.js.map +1 -0
- package/dist/src/core/services/HelperService.d.ts +14 -3
- package/dist/src/core/services/HelperService.d.ts.map +1 -1
- package/dist/src/core/services/HelperService.js +32 -2
- package/dist/src/core/services/HelperService.js.map +1 -1
- package/dist/src/core/services/HttpService.d.ts +8 -0
- package/dist/src/core/services/HttpService.d.ts.map +1 -1
- package/dist/src/core/services/HttpService.js +121 -47
- package/dist/src/core/services/HttpService.js.map +1 -1
- package/dist/src/core/services/MarkerRegistry.d.ts +51 -8
- package/dist/src/core/services/MarkerRegistry.d.ts.map +1 -1
- package/dist/src/core/services/MarkerRegistry.js +135 -14
- package/dist/src/core/services/MarkerRegistry.js.map +1 -1
- package/dist/src/core/services/PageCache.d.ts +101 -0
- package/dist/src/core/services/PageCache.d.ts.map +1 -0
- package/dist/src/core/services/PageCache.js +184 -0
- package/dist/src/core/services/PageCache.js.map +1 -0
- package/dist/src/core/services/SectionManager.d.ts +100 -0
- package/dist/src/core/services/SectionManager.d.ts.map +1 -0
- package/dist/src/core/services/SectionManager.js +408 -0
- package/dist/src/core/services/SectionManager.js.map +1 -0
- package/dist/src/core/services/StorageService.js +1 -1
- package/dist/src/core/services/TranslationService.d.ts +21 -0
- package/dist/src/core/services/TranslationService.d.ts.map +1 -0
- package/dist/src/core/services/TranslationService.js +95 -0
- package/dist/src/core/services/TranslationService.js.map +1 -0
- package/dist/src/core/services/index.d.ts +11 -8
- package/dist/src/core/services/index.d.ts.map +1 -1
- package/dist/src/core/services/index.js +11 -8
- package/dist/src/core/services/index.js.map +1 -1
- package/dist/src/core/types/utils.d.ts +8 -8
- package/dist/src/core/types/utils.d.ts.map +1 -1
- package/dist/src/core/view/LoopContext.d.ts +21 -1
- package/dist/src/core/view/LoopContext.d.ts.map +1 -1
- package/dist/src/core/view/LoopContext.js +36 -0
- package/dist/src/core/view/LoopContext.js.map +1 -1
- package/dist/src/core/view/ResourceScope.d.ts +38 -0
- package/dist/src/core/view/ResourceScope.d.ts.map +1 -0
- package/dist/src/core/view/ResourceScope.js +167 -0
- package/dist/src/core/view/ResourceScope.js.map +1 -0
- package/dist/src/core/view/Section.d.ts +17 -32
- package/dist/src/core/view/Section.d.ts.map +1 -1
- package/dist/src/core/view/Section.js +14 -55
- package/dist/src/core/view/Section.js.map +1 -1
- package/dist/src/core/view/View.d.ts +43 -7
- package/dist/src/core/view/View.d.ts.map +1 -1
- package/dist/src/core/view/View.js +22 -4
- package/dist/src/core/view/View.js.map +1 -1
- package/dist/src/core/view/ViewController.d.ts +313 -40
- package/dist/src/core/view/ViewController.d.ts.map +1 -1
- package/dist/src/core/view/ViewController.js +1002 -147
- package/dist/src/core/view/ViewController.js.map +1 -1
- package/dist/src/core/view/ViewManager.d.ts +318 -27
- package/dist/src/core/view/ViewManager.d.ts.map +1 -1
- package/dist/src/core/view/ViewManager.js +1199 -258
- package/dist/src/core/view/ViewManager.js.map +1 -1
- package/dist/src/core/view/ViewState.d.ts +185 -6
- package/dist/src/core/view/ViewState.d.ts.map +1 -1
- package/dist/src/core/view/ViewState.js +851 -123
- package/dist/src/core/view/ViewState.js.map +1 -1
- package/dist/src/core/view/index.d.ts +5 -5
- package/dist/src/core/view/index.d.ts.map +1 -1
- package/dist/src/core/view/index.js +5 -5
- package/dist/src/core/view/index.js.map +1 -1
- package/dist/src/testing/index.d.ts +69 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +140 -0
- package/dist/src/testing/index.js.map +1 -0
- package/index.ts +29 -5
- package/package.json +30 -21
- package/plugins.ts +2 -2
- package/src/core/app/Application.ts +58 -31
- package/src/core/bootstrap/app.ts +80 -6
- package/src/core/bootstrap/bundle.ts +101 -0
- package/src/core/bootstrap/providers/ApiServiceProvider.ts +4 -4
- package/src/core/bootstrap/providers/CoreServiceProvider.ts +14 -8
- package/src/core/bootstrap/providers/HelperServiceProvider.ts +8 -3
- package/src/core/bootstrap/providers/RouteServiceProvider.ts +15 -4
- package/src/core/bootstrap/providers/ServiceProvider.ts +8 -8
- package/src/core/bootstrap/providers/ViewServiceProvider.ts +3 -3
- package/src/core/bootstrap/providers/default-providers.ts +51 -16
- package/src/core/bootstrap/providers/index.ts +9 -9
- package/src/core/bootstrap/providers/provider-order.test.ts +16 -27
- package/src/core/bootstrap/providers/provider-order.ts +1 -1
- package/src/core/bootstrap/ssr.ts +136 -0
- package/src/core/cache/ViewCache.ts +1 -1
- package/src/core/cache/index.ts +1 -1
- package/src/core/contracts/ApplicationInterface.ts +13 -11
- package/src/core/contracts/BlockInterface.ts +18 -18
- package/src/core/contracts/BundleInterface.ts +40 -0
- package/src/core/contracts/ComponentInterface.ts +7 -6
- package/src/core/contracts/ElementInterface.ts +95 -27
- package/src/core/contracts/HeadServiceInterface.ts +41 -0
- package/src/core/contracts/HelperInterface.ts +9 -1
- package/src/core/contracts/HttpServiceInterface.ts +3 -0
- package/src/core/contracts/MarkerInterface.ts +22 -49
- package/src/core/contracts/ReactiveInterface.ts +9 -9
- package/src/core/contracts/RouterInterface.ts +5 -2
- package/src/core/contracts/SectionInterface.ts +49 -0
- package/src/core/contracts/ServiceProviderInterface.ts +3 -3
- package/src/core/contracts/ViewControllerInterface.ts +124 -16
- package/src/core/contracts/ViewInterface.ts +34 -5
- package/src/core/contracts/ViewManagerInterface.ts +40 -5
- package/src/core/contracts/ViewStateInterface.ts +39 -2
- package/src/core/contracts/common.ts +3 -3
- package/src/core/contracts/utils.ts +19 -19
- package/src/core/contracts/views.ts +2 -22
- package/src/core/devtools/hook.ts +142 -0
- package/src/core/devtools/index.ts +38 -0
- package/src/core/devtools/inspector.ts +270 -0
- package/src/core/elements/Block.ts +43 -21
- package/src/core/elements/BlockOutlet.ts +61 -21
- package/src/core/elements/Component.ts +352 -18
- package/src/core/elements/ElementManager.ts +9 -9
- package/src/core/elements/ForeachSlotCache.ts +170 -0
- package/src/core/elements/Fragment.ts +50 -32
- package/src/core/elements/Html.ts +696 -117
- package/src/core/elements/Output.ts +147 -30
- package/src/core/elements/Reactive.ts +329 -52
- package/src/core/elements/TextElement.ts +25 -16
- package/src/core/elements/Wrapper.ts +84 -18
- package/src/core/elements/Yield.ts +35 -24
- package/src/core/elements/helpers.ts +12 -12
- package/src/core/elements/index.ts +25 -24
- package/src/core/elements/reconcileChildren.ts +70 -0
- package/src/core/{hellpers → helpers}/ApiClient.ts +8 -1
- package/src/core/{hellpers → helpers}/app.ts +13 -2
- package/src/core/helpers/transition.ts +127 -0
- package/src/core/{hellpers → helpers}/utils.ts +4 -4
- package/src/core/helpers/view.ts +283 -0
- package/src/core/routers/Router.ts +458 -64
- package/src/core/routers/index.ts +2 -2
- package/src/core/services/AssetManager.ts +387 -0
- package/src/core/services/BlockManager.ts +272 -50
- package/src/core/services/DomService.ts +3 -3
- package/src/core/services/HeadService.ts +124 -0
- package/src/core/services/HelperService.ts +34 -4
- package/src/core/services/HttpService.ts +116 -55
- package/src/core/services/MarkerRegistry.ts +142 -16
- package/src/core/services/PageCache.ts +239 -0
- package/src/core/services/SectionManager.ts +387 -0
- package/src/core/services/StorageService.ts +1 -1
- package/src/core/services/TranslationService.ts +91 -0
- package/src/core/services/index.ts +11 -8
- package/src/core/types/utils.ts +8 -8
- package/src/core/view/LoopContext.ts +38 -1
- package/src/core/view/ResourceScope.ts +174 -0
- package/src/core/view/Section.ts +21 -68
- package/src/core/view/View.ts +57 -8
- package/src/core/view/ViewController.ts +1070 -186
- package/src/core/view/ViewManager.ts +1346 -272
- package/src/core/view/ViewState.ts +834 -116
- package/src/core/view/index.ts +5 -5
- package/src/testing/index.ts +184 -0
- package/dist/src/core/services/MarkerService.d.ts +0 -299
- package/dist/src/core/services/MarkerService.d.ts.map +0 -1
- package/dist/src/core/services/MarkerService.js +0 -723
- package/dist/src/core/services/MarkerService.js.map +0 -1
- 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
|
|
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
|
|
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
|
-
|
|
101
|
-
if (key
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
this.
|
|
146
|
-
|
|
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
|
-
|
|
211
|
-
if (Array.isArray(key)) {
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
373
|
-
this.
|
|
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;
|