@saolabs/client 1.0.0 → 1.1.0
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 +25 -15
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +55 -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 +77 -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 +129 -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 +15 -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/helpers/ApiClient.d.ts.map +1 -0
- package/dist/src/core/{hellpers → helpers}/ApiClient.js +10 -1
- 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/{hellpers → helpers}/app.js +11 -1
- 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/{hellpers → helpers}/utils.d.ts +2 -2
- package/dist/src/core/helpers/utils.d.ts.map +1 -0
- package/dist/src/core/{hellpers → helpers}/utils.js +4 -4
- 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 +72 -31
- package/src/core/bootstrap/app.ts +84 -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 +141 -0
- package/src/core/cache/ViewCache.ts +1 -1
- package/src/core/cache/index.ts +1 -1
- package/src/core/contracts/ApplicationInterface.ts +15 -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/hellpers/ApiClient.d.ts.map +0 -1
- package/dist/src/core/hellpers/ApiClient.js.map +0 -1
- package/dist/src/core/hellpers/app.d.ts +0 -3
- package/dist/src/core/hellpers/app.d.ts.map +0 -1
- package/dist/src/core/hellpers/app.js.map +0 -1
- package/dist/src/core/hellpers/utils.d.ts.map +0 -1
- package/dist/src/core/hellpers/utils.js.map +0 -1
- 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
- /package/dist/src/core/{hellpers → helpers}/ApiClient.d.ts +0 -0
|
@@ -1,30 +1,41 @@
|
|
|
1
|
-
import type { BlockInterface, BlockOutletInterface, BlockRenderFactory } from "../contracts/BlockInterface";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
import type { BlockInterface, BlockOutletInterface, BlockRenderFactory } from "../contracts/BlockInterface.js";
|
|
2
|
+
import type {
|
|
3
|
+
FragmentInterface, HtmlInterface, SaoChildrenFactory, SaoChildrenFactoryOutput,
|
|
4
|
+
SaoChildrenSlotContent, SaoElementEventHandler, SaoNodeInterface, OutputInterface,
|
|
5
|
+
TextInterface, WrapperInterface, YieldInterface, EventModifier
|
|
6
|
+
} from "../contracts/ElementInterface.js";
|
|
7
|
+
import type { LoopContextInterface } from "../contracts/LoopContextInterface.js";
|
|
8
|
+
import type { ReactiveChildrenFactory, ReactiveInterface } from "../contracts/ReactiveInterface.js";
|
|
9
|
+
import type { ViewControllerInterface, ViewType, ViewConfig, ViewRuntimeConfig, ViewControllerConfig, ViewConfigThis, ErrorInfo, ErrorBoundaryHandler } from "../contracts/ViewControllerInterface.js";
|
|
10
|
+
import type { ViewInterface, ViewRenderFactory } from "../contracts/ViewInterface.js";
|
|
11
|
+
import type { ViewStateInterface } from "../contracts/ViewStateInterface.js";
|
|
12
|
+
import type { SaoObjectType } from "../types/utils.js";
|
|
13
|
+
import { ViewState } from "./ViewState.js";
|
|
14
|
+
import { ResourceScope } from './ResourceScope.js';
|
|
15
|
+
import { LoopContext } from "./LoopContext.js";
|
|
16
|
+
import { Reactive } from "../elements/Reactive.js";
|
|
17
|
+
import BlockManager, { BlockManagerService } from "../services/BlockManager.js";
|
|
18
|
+
import SectionManager from "../services/SectionManager.js";
|
|
19
|
+
import devtools from "../devtools/hook.js";
|
|
20
|
+
import { Component } from "../elements/Component.js";
|
|
21
|
+
import { generateUUID } from "../helpers/utils.js";
|
|
22
|
+
import { Output } from "../elements/Output.js";
|
|
23
|
+
import { View } from "./View.js";
|
|
24
|
+
import type { ViewUserConfig } from "./View.js";
|
|
25
|
+
import { ApplicationInterface } from "../contracts/ApplicationInterface.js";
|
|
26
|
+
import { SectionConstruvtorArgs, SectionContentRenderer, SectionContentType, SectionInterface, SectionItemType } from "../contracts/SectionInterface.js";
|
|
27
|
+
import { Section } from "./Section.js";
|
|
28
|
+
import { InitMode } from "../contracts/common.js";
|
|
29
|
+
import { Block, BlockOutlet, Fragment, Html, TextElement } from "../elements/index.js";
|
|
30
|
+
import { Wrapper } from "../elements/Wrapper.js";
|
|
31
|
+
import { YieldElement } from "../elements/Yield.js";
|
|
32
|
+
import { app } from "../helpers/app.js";
|
|
33
|
+
import { ComponentInterface } from "../contracts/ComponentInterface.js";
|
|
34
|
+
import { ForeachSlotCache } from "../elements/ForeachSlotCache.js";
|
|
35
|
+
import { reuseStaticText } from '../elements/reconcileChildren.js';
|
|
36
|
+
import AssetManager, { StyleSpec, ScriptSpec } from "../services/AssetManager.js";
|
|
37
|
+
|
|
38
|
+
type ElementChild = ReactiveInterface | ComponentInterface | HtmlInterface | TextInterface | FragmentInterface | OutputInterface | BlockOutletInterface | YieldInterface | SaoNodeInterface;
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
41
|
* ViewController — the brain behind a View.
|
|
@@ -32,7 +43,8 @@ type ElementChild = ReactiveInterface | ComponentInterface | HtmlInterface | Tex
|
|
|
32
43
|
* Manages:
|
|
33
44
|
* - Reactive state (ViewState) — useState, subscribe, batch flush
|
|
34
45
|
* - DOM element tree — via Html, Reactive, TextElement, Fragment
|
|
35
|
-
* -
|
|
46
|
+
* - Events — addEventListener TRỰC TIẾP trên từng element (KHÔNG delegation),
|
|
47
|
+
* dọn hàng loạt bằng một AbortController chung. Giống React 17+/Vue/Svelte.
|
|
36
48
|
* - Loop contexts — @foreach, @for, @while with LoopContext stack
|
|
37
49
|
* - Block management — for layout views with @useBlock
|
|
38
50
|
* - Lifecycle — setup, render, destroy
|
|
@@ -48,9 +60,9 @@ type ElementChild = ReactiveInterface | ComponentInterface | HtmlInterface | Tex
|
|
|
48
60
|
* this.__ctrl__.__foreach(items, (item, key, index, loop) => [...])
|
|
49
61
|
*/
|
|
50
62
|
export class ViewController implements ViewControllerInterface {
|
|
51
|
-
|
|
63
|
+
|
|
52
64
|
// ─── Identity ───────────────────────────────────────────────
|
|
53
|
-
|
|
65
|
+
saoType: SaoObjectType = 'ViewController';
|
|
54
66
|
public viewId: string;
|
|
55
67
|
public path: string;
|
|
56
68
|
public viewType: ViewType;
|
|
@@ -81,10 +93,18 @@ export class ViewController implements ViewControllerInterface {
|
|
|
81
93
|
// ─── Data & Config ──────────────────────────────────────────
|
|
82
94
|
/** Raw input data from route/parent */
|
|
83
95
|
public data: Record<string, any> = {};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Component (@include) đã dựng view này — null với view gốc của route.
|
|
99
|
+
*
|
|
100
|
+
* Đây là lớp trung gian cha↔con: cha khai báo listener tại thẻ, Component
|
|
101
|
+
* giữ bảng đó, con phát qua {@link emit}.
|
|
102
|
+
*/
|
|
103
|
+
public ownerComponent: ComponentInterface | null = null;
|
|
84
104
|
/** User-defined config from setup() */
|
|
85
105
|
private config: ViewConfig = {};
|
|
86
106
|
/** Typed runtime config from compiled $__setup__ */
|
|
87
|
-
private runtimeConfig: ViewRuntimeConfig
|
|
107
|
+
private runtimeConfig: ViewRuntimeConfig = {};
|
|
88
108
|
/** Track own properties to avoid conflicts when setting user config */
|
|
89
109
|
private ownProperties: Set<string> = new Set(['__ctrl__']);
|
|
90
110
|
|
|
@@ -103,7 +123,7 @@ export class ViewController implements ViewControllerInterface {
|
|
|
103
123
|
/** Compiled prerender factory — produces the prerender element tree */
|
|
104
124
|
private prerenderFactory: ViewRenderFactory | null = null;
|
|
105
125
|
|
|
106
|
-
public childrenFactory:
|
|
126
|
+
public childrenFactory: SaoChildrenFactory | null = null;
|
|
107
127
|
|
|
108
128
|
/** Stored render output for lifecycle management */
|
|
109
129
|
public renderOutput: any = null;
|
|
@@ -121,11 +141,40 @@ export class ViewController implements ViewControllerInterface {
|
|
|
121
141
|
private hasScheduledUpdate = false;
|
|
122
142
|
/** Centralized AbortController for all event listeners */
|
|
123
143
|
private eventAbortController: AbortController = new AbortController();
|
|
144
|
+
/** Exact listener references for cleanup when an individual Html node dies. */
|
|
145
|
+
public elementEventHandlers: Map<HTMLElement, Map<string, EventListener[]>> = new Map();
|
|
124
146
|
|
|
125
147
|
// ─── Loop ───────────────────────────────────────────────────
|
|
126
148
|
/** Current loop context stack (@foreach, @for, @while) */
|
|
127
149
|
public loopContext: LoopContext | null = null;
|
|
128
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Active ForeachSlotCache — được set bởi Reactive.renderForeach() trước khi
|
|
153
|
+
* gọi childrenFactory. __foreach() đọc cache này để quyết định reuse hay create.
|
|
154
|
+
* Reset về null ngay sau khi childrenFactory trả về.
|
|
155
|
+
*/
|
|
156
|
+
public _currentForeachCache: ForeachSlotCache | null = null;
|
|
157
|
+
public _foreachSkipRegistry: boolean = false;
|
|
158
|
+
private foreachRegistry: Map<string, any> | null = null;
|
|
159
|
+
private elementRegistries = new WeakMap<object, Map<string, any>>();
|
|
160
|
+
private inlineForeachCaches = new WeakMap<Map<string, any>, Map<string, ForeachSlotCache>>();
|
|
161
|
+
|
|
162
|
+
/** Deferred child factories retain the row scope in which they were created. */
|
|
163
|
+
private scopeFactory<F extends (...args: any[]) => any>(factory: F): F {
|
|
164
|
+
const registry = this.foreachRegistry;
|
|
165
|
+
if (!registry) return factory;
|
|
166
|
+
return ((...args: any[]) => this.inForeachScope(registry, () => factory(...args))) as F;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private inForeachScope<T>(registry: Map<string, any>, run: () => T): T {
|
|
170
|
+
const previous = this.foreachRegistry;
|
|
171
|
+
const skip = this._foreachSkipRegistry;
|
|
172
|
+
this.foreachRegistry = registry;
|
|
173
|
+
this._foreachSkipRegistry = false;
|
|
174
|
+
try { return run(); }
|
|
175
|
+
finally { this.foreachRegistry = previous; this._foreachSkipRegistry = skip; }
|
|
176
|
+
}
|
|
177
|
+
|
|
129
178
|
// ─── Layout (Sections & Blocks) ─────────────────────────────
|
|
130
179
|
/** Section management across views */
|
|
131
180
|
public sections: Map<string, SectionInterface> = new Map();
|
|
@@ -133,9 +182,14 @@ export class ViewController implements ViewControllerInterface {
|
|
|
133
182
|
public blocks: Map<string, BlockInterface> = new Map();
|
|
134
183
|
|
|
135
184
|
public elements: Map<string, ElementChild> = new Map();
|
|
185
|
+
/** element → key của nó trong `elements` (xem registerElement/releaseElement) */
|
|
186
|
+
private elementKeys: WeakMap<object, string> = new WeakMap();
|
|
136
187
|
|
|
137
188
|
public preloadElement: WrapperInterface | null = null; // For pre-rendering elements before the main render
|
|
138
189
|
public mainElement: WrapperInterface | null = null; // The main rendered element tree
|
|
190
|
+
/** Wrapper instance RIÊNG cho render/prerender — dùng chung sẽ làm
|
|
191
|
+
* preloadElement === mainElement → swap skeleton→main destroy nhầm chính nó */
|
|
192
|
+
private wrapperInstances: { render: WrapperInterface | null; prerender: WrapperInterface | null } = { render: null, prerender: null };
|
|
139
193
|
// ─── Lifecycle Flags ────────────────────────────────────────
|
|
140
194
|
/** Whether this view is currently active (mounted in DOM) */
|
|
141
195
|
public isActive: boolean = false;
|
|
@@ -143,8 +197,13 @@ export class ViewController implements ViewControllerInterface {
|
|
|
143
197
|
private _isDataCommitted = false;
|
|
144
198
|
/** Whether the view has been mounted */
|
|
145
199
|
private _isMounted = false;
|
|
200
|
+
/** Whether the reactive element tree has been started. */
|
|
201
|
+
private _isStarted = false;
|
|
146
202
|
/** Whether the view has been fully destroyed */
|
|
147
203
|
private _isDestroyed = false;
|
|
204
|
+
/** Whether this instance's styles/scripts are currently counted in AssetManager
|
|
205
|
+
* (true = đang góp 1 ref vào real DOM). Giữ acquire/release cân bằng. */
|
|
206
|
+
private _assetsLive = false;
|
|
148
207
|
/** For future use: scanning DOM for bindings */
|
|
149
208
|
protected isScanMode: boolean = false;
|
|
150
209
|
|
|
@@ -165,6 +224,67 @@ export class ViewController implements ViewControllerInterface {
|
|
|
165
224
|
this.states = new ViewState(this);
|
|
166
225
|
}
|
|
167
226
|
|
|
227
|
+
private _scope?: ResourceScope;
|
|
228
|
+
get scope(): ResourceScope {
|
|
229
|
+
if (!this._scope) {
|
|
230
|
+
this._scope = new ResourceScope(this.states.__, callback => this.afterDom(callback), error => {
|
|
231
|
+
if (!this.handleError(error, { phase: 'update', path: this.path }).handled) {
|
|
232
|
+
console.error(`[ViewController] Watch error in "${this.path}":`, error);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
if (this._isDestroyed) this._scope.destroy();
|
|
236
|
+
else if (this._isStarted && this._lifecycleState === 'active') this._scope.resume();
|
|
237
|
+
}
|
|
238
|
+
return this._scope;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private afterDomCallbacks = new Set<() => void>();
|
|
242
|
+
private afterDomRAF: number | null = null;
|
|
243
|
+
private afterDomRevision = 0;
|
|
244
|
+
|
|
245
|
+
/** One-shot callback after this view's state and DOM queues settle. */
|
|
246
|
+
afterDom(callback: () => void): () => void {
|
|
247
|
+
if (this._isDestroyed || this._lifecycleState === 'paused') return () => {};
|
|
248
|
+
this.afterDomCallbacks.add(callback);
|
|
249
|
+
if (this.afterDomRAF === null) {
|
|
250
|
+
this.afterDomRAF = requestAnimationFrame(() => {
|
|
251
|
+
this.afterDomRAF = null;
|
|
252
|
+
const revision = this.afterDomRevision;
|
|
253
|
+
this.states.__.flushNow();
|
|
254
|
+
if (revision === this.afterDomRevision) this.flushReactiveUpdatesNow();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return () => {
|
|
258
|
+
this.afterDomCallbacks.delete(callback);
|
|
259
|
+
if (this.afterDomCallbacks.size === 0 && this.afterDomRAF !== null) {
|
|
260
|
+
cancelAnimationFrame(this.afterDomRAF);
|
|
261
|
+
this.afterDomRAF = null;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private flushAfterDom(): void {
|
|
267
|
+
if (this._isDestroyed || this._lifecycleState === 'paused') return;
|
|
268
|
+
this.afterDomRevision++;
|
|
269
|
+
if (this.afterDomRAF !== null) cancelAnimationFrame(this.afterDomRAF);
|
|
270
|
+
this.afterDomRAF = null;
|
|
271
|
+
// Callbacks queued by another callback belong to the next frame.
|
|
272
|
+
const callbacks = Array.from(this.afterDomCallbacks);
|
|
273
|
+
for (const callback of callbacks) {
|
|
274
|
+
if (!this.afterDomCallbacks.delete(callback)) continue;
|
|
275
|
+
try { callback(); }
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (!this.handleError(error, { phase: 'update', path: this.path }).handled) console.error(error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private cancelAfterDom(): void {
|
|
283
|
+
if (this.afterDomRAF !== null) cancelAnimationFrame(this.afterDomRAF);
|
|
284
|
+
this.afterDomRAF = null;
|
|
285
|
+
this.afterDomCallbacks.clear();
|
|
286
|
+
}
|
|
287
|
+
|
|
168
288
|
// ─── Lifecycle ──────────────────────────────────────────────
|
|
169
289
|
|
|
170
290
|
/**
|
|
@@ -174,7 +294,7 @@ export class ViewController implements ViewControllerInterface {
|
|
|
174
294
|
* CHƯA gọi commitConstructorData — đợi ViewManager gọi commitData().
|
|
175
295
|
*/
|
|
176
296
|
setup(config: ViewRuntimeConfig): void {
|
|
177
|
-
this.runtimeConfig = config
|
|
297
|
+
this.runtimeConfig = config;
|
|
178
298
|
|
|
179
299
|
// Extract metadata
|
|
180
300
|
if (config.viewId) this.viewId = config.viewId;
|
|
@@ -195,7 +315,7 @@ export class ViewController implements ViewControllerInterface {
|
|
|
195
315
|
|
|
196
316
|
getConfig(key?: string, defaultValue?: any): ViewControllerConfig | any {
|
|
197
317
|
if (key) {
|
|
198
|
-
return this.runtimeConfig
|
|
318
|
+
return this.runtimeConfig[key] ?? (this.config[key] ?? defaultValue);
|
|
199
319
|
}
|
|
200
320
|
return { ...this.runtimeConfig, ...this.config };
|
|
201
321
|
}
|
|
@@ -205,8 +325,46 @@ export class ViewController implements ViewControllerInterface {
|
|
|
205
325
|
this.config = config || {};
|
|
206
326
|
}
|
|
207
327
|
|
|
328
|
+
/** Đang chạy onError của chính controller này — chặn đệ quy nếu handler tự throw. */
|
|
329
|
+
private _handlingError = false;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Error boundary: tìm handler gần nhất theo chuỗi `parent`, gọi nó, trả fallback.
|
|
333
|
+
*
|
|
334
|
+
* Bất biến:
|
|
335
|
+
* - handler trả undefined → coi như KHÔNG xử lý, bubble tiếp lên cha
|
|
336
|
+
* (cho phép chỉ log mà không đổi UI).
|
|
337
|
+
* - handler tự throw → bỏ qua boundary đó, bubble tiếp (không lặp vô hạn).
|
|
338
|
+
* - không boundary nào xử lý → { handled:false }, caller giữ hành vi cũ.
|
|
339
|
+
*/
|
|
340
|
+
handleError(err: unknown, info: ErrorInfo): { handled: boolean; fallback?: any } {
|
|
341
|
+
devtools.emit('error', {
|
|
342
|
+
viewId: this.viewId,
|
|
343
|
+
path: info.path,
|
|
344
|
+
detail: { phase: info.phase, message: err instanceof Error ? err.message : String(err) },
|
|
345
|
+
});
|
|
346
|
+
let ctrl: ViewControllerInterface | null = this;
|
|
347
|
+
while (ctrl) {
|
|
348
|
+
const handler = ctrl.getConfig('onError') as ErrorBoundaryHandler | undefined;
|
|
349
|
+
const self = ctrl as ViewController;
|
|
350
|
+
if (typeof handler === 'function' && !self._handlingError) {
|
|
351
|
+
self._handlingError = true;
|
|
352
|
+
try {
|
|
353
|
+
const fallback = handler.call(ctrl.view, err, info);
|
|
354
|
+
if (fallback !== undefined) return { handled: true, fallback };
|
|
355
|
+
} catch (e) {
|
|
356
|
+
console.error(`[ViewController] onError of "${ctrl.path}" threw:`, e);
|
|
357
|
+
} finally {
|
|
358
|
+
self._handlingError = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
ctrl = ctrl.parent;
|
|
362
|
+
}
|
|
363
|
+
return { handled: false };
|
|
364
|
+
}
|
|
365
|
+
|
|
208
366
|
/** Set user-defined properties/methods on the View instance */
|
|
209
|
-
setUserDefinedConfig(userConfig:
|
|
367
|
+
setUserDefinedConfig<T extends object>(userConfig: ViewUserConfig<T>): void {
|
|
210
368
|
for (const [key, value] of Object.entries(userConfig)) {
|
|
211
369
|
if (!this.ownProperties.has(key)) {
|
|
212
370
|
(this.view as any)[key] = typeof value === 'function' && value.bind
|
|
@@ -245,6 +403,11 @@ export class ViewController implements ViewControllerInterface {
|
|
|
245
403
|
return output;
|
|
246
404
|
}
|
|
247
405
|
this.renderOutput = output;
|
|
406
|
+
// FIX(baseline#9): gán _rootTree để start()/stop()/destroy() theo controller hoạt động.
|
|
407
|
+
// Chỉ gán khi output là element tree (Wrapper) — không gán khi return superView (View).
|
|
408
|
+
if (output && typeof (output as any).start === 'function' && (output as any).saoType !== 'View') {
|
|
409
|
+
this._rootTree = output;
|
|
410
|
+
}
|
|
248
411
|
this.callingMethod = oldCallingMethod;
|
|
249
412
|
return output;
|
|
250
413
|
}
|
|
@@ -257,6 +420,11 @@ export class ViewController implements ViewControllerInterface {
|
|
|
257
420
|
return output;
|
|
258
421
|
}
|
|
259
422
|
this.prerenderOutput = output;
|
|
423
|
+
// Skeleton là tree đang live cho tới khi async render thay thế nó.
|
|
424
|
+
// Controller phải start/pause/destroy được tree này như render tree thường.
|
|
425
|
+
if (output && typeof (output as any).start === 'function' && (output as any).saoType !== 'View') {
|
|
426
|
+
this._rootTree = output;
|
|
427
|
+
}
|
|
260
428
|
this.callingMethod = oldCallingMethod;
|
|
261
429
|
return output;
|
|
262
430
|
}
|
|
@@ -278,12 +446,110 @@ export class ViewController implements ViewControllerInterface {
|
|
|
278
446
|
return output;
|
|
279
447
|
}
|
|
280
448
|
|
|
281
|
-
|
|
449
|
+
// ─── Lifecycle Hooks Dispatcher ─────────────────────────────
|
|
450
|
+
// Bộ hook vòng đời (mẫu examples/sao/app.sao) — mỗi transition một cặp
|
|
451
|
+
// before/after. Hook là method tuỳ chọn trên View (this.view[name]):
|
|
452
|
+
// mount → mounting / mounted (DOM gắn vào real DOM)
|
|
453
|
+
// start → starting / started (subscribe reactive + attach events)
|
|
454
|
+
// pause → pausing / paused (vào PageCache — rời real DOM)
|
|
455
|
+
// resume → resuming / resumed (khôi phục từ PageCache)
|
|
456
|
+
// stop → stopping / stopped (huỷ subscribe — deactivate)
|
|
457
|
+
// unmount → unmounting / unmounted (gỡ DOM khỏi real DOM)
|
|
458
|
+
// destroy → destroying / destroyed (cleanup toàn bộ)
|
|
459
|
+
// Legacy alias (tương thích test/app cũ): onMounted≈started, onPause≈paused,
|
|
460
|
+
// onResume≈resumed, onDeactivated≈stopped, onDestroy≈destroyed.
|
|
461
|
+
|
|
462
|
+
/** Gọi an toàn một lifecycle hook nếu user có định nghĩa trên View. */
|
|
463
|
+
private callHook(name: string): void {
|
|
464
|
+
const fn = (this.view as any)?.[name];
|
|
465
|
+
if (typeof fn === 'function') {
|
|
466
|
+
try {
|
|
467
|
+
const result = fn.call(this.view);
|
|
468
|
+
// Lifecycle transitions are deliberately synchronous. If a user hook
|
|
469
|
+
// is async, do not block navigation, but never leave a rejected promise
|
|
470
|
+
// unhandled either.
|
|
471
|
+
if (result && typeof result.then === 'function') {
|
|
472
|
+
Promise.resolve(result).catch((e) => {
|
|
473
|
+
console.error(`[ViewController] async hook "${name}" error in "${this.path}":`, e);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch (e) { console.error(`[ViewController] hook "${name}" error in "${this.path}":`, e); }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
282
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Mount — instance vào real DOM.
|
|
483
|
+
* - root != null: gắn DOM tree (mainElement/preloadElement) vào root.
|
|
484
|
+
* - root == null: nội dung đã được đặt sẵn (block outlets / component markers),
|
|
485
|
+
* chỉ fire hook + acquire asset.
|
|
486
|
+
* Fire mounting/mounted MỘT lần mỗi vòng đời; pause→resume dùng resuming/resumed.
|
|
487
|
+
*/
|
|
488
|
+
mount(root: HtmlInterface | null = null): void {
|
|
489
|
+
if (this._isDestroyed) return;
|
|
490
|
+
const firstMount = !this._isMounted;
|
|
491
|
+
if (firstMount) this.callHook('mounting');
|
|
492
|
+
|
|
493
|
+
if (root) {
|
|
494
|
+
this.setParentElement(root);
|
|
495
|
+
const el = this.mainElement ?? this.preloadElement;
|
|
496
|
+
if (el) {
|
|
497
|
+
el.setParentElement(root);
|
|
498
|
+
el.mountTo(root);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this._isMounted = true;
|
|
503
|
+
this.acquireAssets();
|
|
504
|
+
if (firstMount) {
|
|
505
|
+
this.callHook('mounted');
|
|
506
|
+
devtools.emit('view:mounted', { viewId: this.viewId, path: this.path });
|
|
507
|
+
}
|
|
283
508
|
}
|
|
284
509
|
|
|
510
|
+
/** Unmount — gỡ instance khỏi real DOM (fire hook + release asset). */
|
|
285
511
|
unmount(): void {
|
|
286
|
-
|
|
512
|
+
if (this._isDestroyed || !this._isMounted) return;
|
|
513
|
+
this.callHook('unmounting');
|
|
514
|
+
this.releaseAssets();
|
|
515
|
+
this._isMounted = false;
|
|
516
|
+
this.callHook('unmounted');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ─── Style/Script asset (AssetManager, ref-count theo path) ──
|
|
520
|
+
|
|
521
|
+
/** Đăng ký style/script của component vào real DOM (insert khi ref 0→1). */
|
|
522
|
+
private acquireAssets(): void {
|
|
523
|
+
if (this._assetsLive) return;
|
|
524
|
+
const styles = this.getConfig('styles') as StyleSpec[] | undefined;
|
|
525
|
+
const scripts = this.getConfig('scripts') as ScriptSpec[] | undefined;
|
|
526
|
+
if ((!styles || !styles.length) && (!scripts || !scripts.length)) return;
|
|
527
|
+
this._assetsLive = true;
|
|
528
|
+
AssetManager.acquire(this.path, styles, scripts, this.getSubtreeRoots());
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Gỡ đăng ký style/script (remove khi ref 1→0). */
|
|
532
|
+
private releaseAssets(): void {
|
|
533
|
+
if (!this._assetsLive) return;
|
|
534
|
+
this._assetsLive = false;
|
|
535
|
+
const styles = this.getConfig('styles') as StyleSpec[] | undefined;
|
|
536
|
+
const scripts = this.getConfig('scripts') as ScriptSpec[] | undefined;
|
|
537
|
+
AssetManager.release(this.path, styles, scripts);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Các Element gốc của instance (giữa markers của Wrapper) — để tag scoped style. */
|
|
541
|
+
private getSubtreeRoots(): Node[] {
|
|
542
|
+
const wrapper: any = this.mainElement ?? this.preloadElement;
|
|
543
|
+
const open: Node | null = wrapper?.openTag ?? null;
|
|
544
|
+
const close: Node | null = wrapper?.closeTag ?? null;
|
|
545
|
+
if (!open || !close || !open.parentNode) return [];
|
|
546
|
+
const roots: Node[] = [];
|
|
547
|
+
let cur: Node | null = open.nextSibling;
|
|
548
|
+
while (cur && cur !== close) {
|
|
549
|
+
if (cur.nodeType === 1) roots.push(cur);
|
|
550
|
+
cur = cur.nextSibling;
|
|
551
|
+
}
|
|
552
|
+
return roots;
|
|
287
553
|
}
|
|
288
554
|
|
|
289
555
|
/**
|
|
@@ -294,21 +560,137 @@ export class ViewController implements ViewControllerInterface {
|
|
|
294
560
|
* This ensures initial state values are set before subscriptions fire.
|
|
295
561
|
*/
|
|
296
562
|
start(): void {
|
|
297
|
-
if (this._isDestroyed ||
|
|
563
|
+
if (this._isDestroyed || this._isStarted) return;
|
|
564
|
+
// Page extends layout không có _rootTree (render trả về superView) —
|
|
565
|
+
// tree thật của nó là block content, được BlockManager.startAll() kích hoạt.
|
|
566
|
+
if (!this._rootTree && this.blocks.size === 0) return;
|
|
567
|
+
|
|
568
|
+
this.callHook('starting');
|
|
569
|
+
this._isStarted = true;
|
|
298
570
|
|
|
299
571
|
// Recursively start all children (Output, TextElement, Html, Reactive, Fragment)
|
|
300
|
-
if ('start' in this._rootTree && typeof this._rootTree.start === 'function') {
|
|
572
|
+
if (this._rootTree && 'start' in this._rootTree && typeof this._rootTree.start === 'function') {
|
|
301
573
|
this._rootTree.start();
|
|
302
574
|
}
|
|
303
575
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
576
|
+
this._lifecycleState = 'active';
|
|
577
|
+
this.isActive = true;
|
|
578
|
+
|
|
579
|
+
this._scope?.resume();
|
|
580
|
+
|
|
581
|
+
this.callHook('started');
|
|
582
|
+
this.callHook('onMounted'); // legacy alias
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ─── Pause / Resume (PageCache lifecycle) ───────────────────
|
|
586
|
+
// State machine: created → active ⇄ paused → destroyed
|
|
587
|
+
// Thiết kế: ROUTE_RENDER_FLOW.md §7. pause/resume là API công khai;
|
|
588
|
+
// stop()/start() là chi tiết nội bộ + đường mount ban đầu.
|
|
589
|
+
|
|
590
|
+
private _lifecycleState: 'created' | 'active' | 'paused' | 'destroyed' = 'created';
|
|
591
|
+
/** Data nhận được trong lúc paused (async fetch về muộn) — apply khi resume */
|
|
592
|
+
private _bufferedData: Record<string, any> | null = null;
|
|
593
|
+
/** Only children paused by this controller may be resumed by it. */
|
|
594
|
+
private _pausedChildren: ViewControllerInterface[] = [];
|
|
595
|
+
|
|
596
|
+
get lifecycleState(): string {
|
|
597
|
+
return this._lifecycleState;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Pause — tạm dừng để vào PageCache:
|
|
602
|
+
* 1. Flush nốt RAF pending (không mất update đang chờ)
|
|
603
|
+
* 2. State listeners → dirty-mode (KHÔNG unsubscribe — ghi sổ key đổi)
|
|
604
|
+
* 3. Hook onPause cho user dọn tài nguyên thô
|
|
605
|
+
* DOM event listeners giữ nguyên — DOM sẽ bị detach nên vô hại.
|
|
606
|
+
*/
|
|
607
|
+
pause(): void {
|
|
608
|
+
if (this._lifecycleState !== 'active') {
|
|
609
|
+
if (this._lifecycleState === 'paused') return;
|
|
610
|
+
console.warn(`[ViewController] pause() bị bỏ qua: "${this.path}" đang ở trạng thái "${this._lifecycleState}".`);
|
|
611
|
+
return;
|
|
311
612
|
}
|
|
613
|
+
|
|
614
|
+
this.callHook('pausing');
|
|
615
|
+
this._scope?.pause();
|
|
616
|
+
this.cancelAfterDom();
|
|
617
|
+
|
|
618
|
+
// 1. Flush nốt mọi update đang chờ → DOM là snapshot nhất quán
|
|
619
|
+
this.states.__.flushNow();
|
|
620
|
+
this.flushReactiveUpdatesNow();
|
|
621
|
+
|
|
622
|
+
// Parent pauses before descendants; descendants finish before parent.
|
|
623
|
+
// Snapshot prevents resuming children that were already inactive on entry.
|
|
624
|
+
this._pausedChildren = this.children.filter((child) => child.lifecycleState === 'active');
|
|
625
|
+
for (let i = this._pausedChildren.length - 1; i >= 0; i--) {
|
|
626
|
+
this._pausedChildren[i].pause();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 2. Dirty-mode
|
|
630
|
+
this.states.__.pause();
|
|
631
|
+
|
|
632
|
+
this._lifecycleState = 'paused';
|
|
633
|
+
this.isActive = false;
|
|
634
|
+
|
|
635
|
+
// 3. Rời real DOM (sắp detach vào PageCache) → gỡ style/script (ref--)
|
|
636
|
+
this.releaseAssets();
|
|
637
|
+
|
|
638
|
+
// 4. Hook
|
|
639
|
+
this.callHook('paused');
|
|
640
|
+
this.callHook('onPause'); // legacy alias
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resume — khôi phục từ PageCache:
|
|
645
|
+
* 1. Apply buffered data (async về trong lúc paused)
|
|
646
|
+
* 2. Thoát dirty-mode → flush đúng các vùng phụ thuộc dirty keys
|
|
647
|
+
* 3. Hook onResume — nơi user quyết định refetch (theo TTL riêng...)
|
|
648
|
+
*/
|
|
649
|
+
resume(): void {
|
|
650
|
+
if (this._lifecycleState !== 'paused') {
|
|
651
|
+
console.warn(`[ViewController] resume() bị bỏ qua: "${this.path}" đang ở trạng thái "${this._lifecycleState}".`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.callHook('resuming');
|
|
656
|
+
|
|
657
|
+
this._lifecycleState = 'active';
|
|
658
|
+
this.isActive = true;
|
|
659
|
+
|
|
660
|
+
// 1. Quay lại real DOM → insert lại style/script (ref++)
|
|
661
|
+
this.acquireAssets();
|
|
662
|
+
|
|
663
|
+
// 2. Buffered async data → updateData thật (key đổi sẽ vào pending → flush ngay sau)
|
|
664
|
+
const buffered = this._bufferedData;
|
|
665
|
+
this._bufferedData = null;
|
|
666
|
+
|
|
667
|
+
// 3. Thoát dirty-mode; resume() của StateManager tự notify các key dirty
|
|
668
|
+
this.states.__.resume();
|
|
669
|
+
|
|
670
|
+
if (buffered) {
|
|
671
|
+
this.updateData(buffered);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Resume outside-in: parent state is live before child subscriptions flush.
|
|
675
|
+
const pausedChildren = this._pausedChildren;
|
|
676
|
+
this._pausedChildren = [];
|
|
677
|
+
for (const child of pausedChildren) {
|
|
678
|
+
if (child.lifecycleState === 'paused') child.resume();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
this._scope?.resume();
|
|
682
|
+
|
|
683
|
+
// 4. Hook
|
|
684
|
+
this.callHook('resumed');
|
|
685
|
+
this.callHook('onResume'); // legacy alias
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Flush đồng bộ các reactive update đang chờ RAF */
|
|
689
|
+
flushReactiveUpdatesNow(): void {
|
|
690
|
+
if (this.hasScheduledUpdate || this.pendingReactiveUpdates.size > 0) {
|
|
691
|
+
this.flushReactiveUpdates();
|
|
692
|
+
this.hasScheduledUpdate = false;
|
|
693
|
+
} else this.flushAfterDom();
|
|
312
694
|
}
|
|
313
695
|
|
|
314
696
|
/**
|
|
@@ -316,44 +698,70 @@ export class ViewController implements ViewControllerInterface {
|
|
|
316
698
|
* DOM stays intact but reactive updates are paused.
|
|
317
699
|
*/
|
|
318
700
|
stop(): void {
|
|
319
|
-
if (
|
|
701
|
+
if (!this._isStarted) return;
|
|
702
|
+
|
|
703
|
+
this.callHook('stopping');
|
|
704
|
+
this._isStarted = false;
|
|
705
|
+
this._scope?.pause();
|
|
706
|
+
this.cancelAfterDom();
|
|
320
707
|
|
|
321
708
|
// Recursively stop all children
|
|
322
|
-
if ('stop' in this._rootTree && typeof this._rootTree.stop === 'function') {
|
|
709
|
+
if (this._rootTree && 'stop' in this._rootTree && typeof this._rootTree.stop === 'function') {
|
|
323
710
|
this._rootTree.stop();
|
|
324
711
|
}
|
|
325
712
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
} catch (e) {
|
|
331
|
-
console.error(`[ViewController] onDeactivated error in "${this.path}":`, e);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
713
|
+
this.isActive = false;
|
|
714
|
+
|
|
715
|
+
this.callHook('stopped');
|
|
716
|
+
this.callHook('onDeactivated'); // legacy alias
|
|
334
717
|
}
|
|
335
718
|
|
|
336
719
|
/** Full destroy — cleanup everything */
|
|
337
720
|
destroy(): void {
|
|
338
721
|
if (this._isDestroyed) return;
|
|
339
|
-
this._isDestroyed = true;
|
|
340
722
|
|
|
341
|
-
|
|
723
|
+
this.callHook('destroying');
|
|
724
|
+
|
|
725
|
+
// stop() must run while the instance is still valid so user cleanup hooks
|
|
726
|
+
// and the element tree are not skipped.
|
|
342
727
|
this.stop();
|
|
343
728
|
|
|
729
|
+
// Gỡ style/script nếu instance còn đang giữ ref (chưa qua pause)
|
|
730
|
+
this.releaseAssets();
|
|
731
|
+
|
|
732
|
+
this._isDestroyed = true;
|
|
733
|
+
this._lifecycleState = 'destroyed';
|
|
734
|
+
this._scope?.destroy();
|
|
735
|
+
this.cancelAfterDom();
|
|
736
|
+
|
|
344
737
|
// Cancel pending updates
|
|
345
738
|
this.pendingReactiveUpdates.clear();
|
|
346
739
|
this.hasScheduledUpdate = false;
|
|
347
740
|
|
|
348
741
|
// Abort all event listeners
|
|
349
742
|
this.eventAbortController.abort();
|
|
743
|
+
this.elementEventHandlers.clear();
|
|
744
|
+
|
|
745
|
+
// Sắp gỡ DOM khỏi real DOM
|
|
746
|
+
const wasMounted = this._isMounted;
|
|
747
|
+
if (wasMounted) this.callHook('unmounting');
|
|
350
748
|
|
|
351
749
|
// Destroy root tree
|
|
352
750
|
if (this._rootTree && 'destroy' in this._rootTree && typeof this._rootTree.destroy === 'function') {
|
|
353
751
|
this._rootTree.destroy();
|
|
354
752
|
}
|
|
355
753
|
this._rootTree = null;
|
|
754
|
+
this._isMounted = false;
|
|
755
|
+
|
|
756
|
+
// A child controller can exist outside _rootTree (for layout/block paths).
|
|
757
|
+
// Destroy remaining children in reverse ownership order; destroy is idempotent.
|
|
758
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
759
|
+
this.children[i].destroy();
|
|
760
|
+
}
|
|
761
|
+
this.children = [];
|
|
762
|
+
this._pausedChildren = [];
|
|
356
763
|
|
|
764
|
+
if (wasMounted) this.callHook('unmounted');
|
|
357
765
|
|
|
358
766
|
// Destroy state
|
|
359
767
|
this.states.__.destroy();
|
|
@@ -361,45 +769,175 @@ export class ViewController implements ViewControllerInterface {
|
|
|
361
769
|
// Cleanup loop context stack
|
|
362
770
|
this.loopContext = null;
|
|
363
771
|
|
|
364
|
-
// Fire onDestroy hook
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
772
|
+
// Fire onDestroy hook (legacy alias của 'destroyed')
|
|
773
|
+
this.callHook('onDestroy');
|
|
774
|
+
|
|
775
|
+
// Gỡ block content + outlets của view này khỏi BlockManager (tránh leak qua navigation)
|
|
776
|
+
BlockManager.unmountView(this.viewId);
|
|
777
|
+
BlockManager.removeOutletsOfView(this.viewId);
|
|
778
|
+
|
|
779
|
+
// Gỡ sections + yields của view này khỏi SectionManager (tránh leak qua navigation)
|
|
780
|
+
SectionManager.unmountView(this.viewId);
|
|
781
|
+
SectionManager.removeYieldsOfView(this.viewId);
|
|
782
|
+
|
|
783
|
+
// Clear element registry — tránh giữ reference corpse elements (memory leak)
|
|
784
|
+
this.elements.clear();
|
|
785
|
+
this.sections.clear();
|
|
786
|
+
this.blocks.clear();
|
|
787
|
+
|
|
788
|
+
const parent = this.parent;
|
|
789
|
+
this.parent = null;
|
|
790
|
+
parent?.removeChild(this);
|
|
372
791
|
|
|
373
792
|
// Nullify references
|
|
374
793
|
this.rootElement = null;
|
|
375
794
|
this.renderFactory = null;
|
|
376
795
|
this.prerenderFactory = null;
|
|
377
796
|
this._isMounted = false;
|
|
797
|
+
|
|
798
|
+
this.callHook('destroyed');
|
|
799
|
+
devtools.emit('view:destroyed', { viewId: this.viewId, path: this.path });
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
active(): void {
|
|
803
|
+
this.isActive = true;
|
|
804
|
+
}
|
|
805
|
+
deactive(): void {
|
|
806
|
+
this.isActive = false;
|
|
378
807
|
}
|
|
379
808
|
|
|
380
809
|
// ─── Data Lifecycle ─────────────────────────────────────────
|
|
381
810
|
|
|
811
|
+
/**
|
|
812
|
+
* `this` context cho các hàm compiled config (updateVariableData dùng
|
|
813
|
+
* this.config.updateVariableItemData và this.data).
|
|
814
|
+
*/
|
|
815
|
+
private makeConfigThis(): ViewConfigThis {
|
|
816
|
+
return {
|
|
817
|
+
config: this.runtimeConfig,
|
|
818
|
+
data: this.data,
|
|
819
|
+
ctrl: this,
|
|
820
|
+
view: this.view,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
382
824
|
/**
|
|
383
825
|
* Commit initial data — set initial state values.
|
|
384
826
|
* Called by ViewManager AFTER render + block mounting.
|
|
385
|
-
*
|
|
827
|
+
*
|
|
386
828
|
* Flow: commitConstructorData() → update$xxx(initial) → lockUpdateRealState()
|
|
829
|
+
* FIX(Phase2): trước đây method này RỖNG — compiled commitConstructorData
|
|
830
|
+
* không bao giờ được gọi.
|
|
387
831
|
*/
|
|
388
832
|
commitData(): void {
|
|
833
|
+
if (this._isDataCommitted || this._isDestroyed) return;
|
|
834
|
+
const fn = this.runtimeConfig.commitConstructorData;
|
|
835
|
+
if (typeof fn === 'function') {
|
|
836
|
+
try {
|
|
837
|
+
fn.call(this.makeConfigThis());
|
|
838
|
+
} catch (e) {
|
|
839
|
+
console.error(`[ViewController] commitData error in "${this.path}":`, e);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Lock ENFORCE ở runtime — không phụ thuộc compiler emit lockUpdateRealState()
|
|
843
|
+
// cuối commitConstructorData (view không có state → compiled fn rỗng, không lock).
|
|
844
|
+
// Từ đây update$xxx chỉ chạy được trong cửa sổ unlock của updateData.
|
|
845
|
+
this.states.__.lockUpdateRealState();
|
|
846
|
+
this._isDataCommitted = true;
|
|
389
847
|
}
|
|
390
848
|
|
|
391
849
|
/**
|
|
392
|
-
* Update data from external source (navigate same view,
|
|
393
|
-
*
|
|
850
|
+
* Update data from external source (navigate same view, props từ parent...).
|
|
851
|
+
*
|
|
852
|
+
* Contract data vs state (mô hình React — data:props từ ngoài, state:của instance):
|
|
853
|
+
* - TRƯỚC commitData (constructor phase): chỉ merge vào this.data. Factory
|
|
854
|
+
* đã destructure __data__ lúc khởi tạo; state sẽ do commitData() init MỘT
|
|
855
|
+
* lần. KHÔNG chạy updateVariableData ở đây — nếu chạy, sequence
|
|
856
|
+
* unlock→lock của nó làm commitConstructorData về sau thành no-op
|
|
857
|
+
* (update$xxx bị lock chặn) → state init phụ thuộc data rỗng hay không.
|
|
858
|
+
* - SAU commitData: đường props-update chuẩn — unlock → updateVariableData
|
|
859
|
+
* (trait cập nhật biến data + notify các key dẫn xuất từ data) → lock.
|
|
860
|
+
* Instance state (init bằng literal) KHÔNG được reset ở đây — đó là
|
|
861
|
+
* trách nhiệm của compiled updateVariableData (COMPILER_CONTRACT).
|
|
862
|
+
* Khi paused: buffer lại, apply lúc resume (ROUTE_RENDER_FLOW §8.2).
|
|
394
863
|
*/
|
|
395
864
|
updateData(newData: Record<string, any>): void {
|
|
396
|
-
this.
|
|
865
|
+
if (this._isDestroyed) return;
|
|
866
|
+
if (this._lifecycleState === 'paused') {
|
|
867
|
+
this._bufferedData = { ...(this._bufferedData ?? {}), ...newData };
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
this.data = { ...this.data, ...newData };
|
|
871
|
+
if (!this._isDataCommitted) {
|
|
872
|
+
// Constructor phase (data mount / async data về TRƯỚC commit):
|
|
873
|
+
// áp data vào biến data qua TRAIT-ONLY (updateVariableItemData từng key)
|
|
874
|
+
// — closure vars nhận giá trị mới để commitData init state từ đó.
|
|
875
|
+
// KHÔNG chạy updateVariableData (tránh re-init state) và KHÔNG
|
|
876
|
+
// đụng lock (commitData cần cửa unlock của constructor phase).
|
|
877
|
+
this.applyDataTrait(newData);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const fn = this.runtimeConfig.updateVariableData;
|
|
881
|
+
if (typeof fn === 'function') {
|
|
882
|
+
this.states.__.unlockUpdateRealState();
|
|
883
|
+
try {
|
|
884
|
+
fn.call(this.makeConfigThis(), newData);
|
|
885
|
+
} catch (e) {
|
|
886
|
+
console.error(`[ViewController] updateData error in "${this.path}":`, e);
|
|
887
|
+
} finally {
|
|
888
|
+
this.states.__.lockUpdateRealState();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Con phát sự kiện lên ĐÚNG cha đã include nó: `emit('edit', card['id'])`.
|
|
895
|
+
*
|
|
896
|
+
* Kênh trực tiếp, không qua App.Event — hai instance cùng view không nghe
|
|
897
|
+
* nhầm của nhau, và không có gì để gỡ đăng ký lúc destroy. Không ai lắng
|
|
898
|
+
* nghe thì im lặng, đúng như một DOM event không listener.
|
|
899
|
+
*
|
|
900
|
+
* Trả về giá trị handler trả về, nên con hỏi cha được (`if (!emit('close'))`).
|
|
901
|
+
*/
|
|
902
|
+
emit(event: string, ...args: any[]): any {
|
|
903
|
+
const fn = this.ownerComponent?.listeners?.[event];
|
|
904
|
+
return typeof fn === 'function' ? fn(...args) : undefined;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Áp data vào biến data (trait) từng key — không đụng state, không đụng lock */
|
|
908
|
+
private applyDataTrait(newData: Record<string, any>): void {
|
|
909
|
+
const itemFn = this.runtimeConfig.updateVariableItemData;
|
|
910
|
+
if (typeof itemFn !== 'function') return;
|
|
911
|
+
for (const key of Object.keys(newData)) {
|
|
912
|
+
try {
|
|
913
|
+
itemFn.call(this.makeConfigThis(), key, newData[key]);
|
|
914
|
+
} catch (e) {
|
|
915
|
+
console.error(`[ViewController] applyDataTrait error in "${this.path}" (key: ${key}):`, e);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
397
918
|
}
|
|
398
919
|
|
|
399
920
|
/**
|
|
400
|
-
* Update single data item.
|
|
921
|
+
* Update single data item — cùng contract với updateData (xem trên).
|
|
401
922
|
*/
|
|
402
923
|
updateDataItem(key: string, value: any): void {
|
|
924
|
+
this.data[key] = value;
|
|
925
|
+
if (!this._isDataCommitted) {
|
|
926
|
+
// Constructor phase: trait-only, không đụng lock (như updateData)
|
|
927
|
+
this.applyDataTrait({ [key]: value });
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const fn = this.runtimeConfig.updateVariableItemData;
|
|
931
|
+
if (typeof fn === 'function') {
|
|
932
|
+
this.states.__.unlockUpdateRealState();
|
|
933
|
+
try {
|
|
934
|
+
fn.call(this.makeConfigThis(), key, value);
|
|
935
|
+
} catch (e) {
|
|
936
|
+
console.error(`[ViewController] updateDataItem error in "${this.path}":`, e);
|
|
937
|
+
} finally {
|
|
938
|
+
this.states.__.lockUpdateRealState();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
403
941
|
}
|
|
404
942
|
|
|
405
943
|
// ─── Event Management ───────────────────────────────────────
|
|
@@ -413,7 +951,37 @@ export class ViewController implements ViewControllerInterface {
|
|
|
413
951
|
* - Object with handler + params: { handler: fn, params: [...] }
|
|
414
952
|
* - Object with string handler (method name on view): { handler: 'handleClick' }
|
|
415
953
|
*/
|
|
416
|
-
|
|
954
|
+
/**
|
|
955
|
+
* Bọc handler theo modifier của `@click.prevent.stop(...)`.
|
|
956
|
+
* `self` kiểm TRƯỚC: event từ element con thì coi như không xảy ra, nên
|
|
957
|
+
* cũng không được preventDefault/stopPropagation (giống Vue).
|
|
958
|
+
* `once` KHÔNG xử lý ở đây — nó là option của addEventListener.
|
|
959
|
+
*/
|
|
960
|
+
private wrapEventModifiers(fn: EventListener, modifiers: EventModifier[]): EventListener {
|
|
961
|
+
const checkSelf = modifiers.includes('self');
|
|
962
|
+
const prevent = modifiers.includes('prevent');
|
|
963
|
+
const stop = modifiers.includes('stop');
|
|
964
|
+
if (!checkSelf && !prevent && !stop) return fn;
|
|
965
|
+
|
|
966
|
+
return function (this: any, event: Event) {
|
|
967
|
+
if (checkSelf && event.target !== event.currentTarget) return;
|
|
968
|
+
if (prevent) event.preventDefault();
|
|
969
|
+
if (stop) event.stopPropagation();
|
|
970
|
+
return (fn as any).call(this, event);
|
|
971
|
+
} as EventListener;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
addEventListener(
|
|
975
|
+
element: HTMLElement,
|
|
976
|
+
event: string,
|
|
977
|
+
handlers: SaoElementEventHandler,
|
|
978
|
+
modifiers: EventModifier[] = [],
|
|
979
|
+
): void {
|
|
980
|
+
// Compiled Html supplies the complete handler list for one event. Replacing
|
|
981
|
+
// the previous set makes render/hydrate initialization idempotent.
|
|
982
|
+
this.removeEventListener(element, event);
|
|
983
|
+
const registered: EventListener[] = [];
|
|
984
|
+
const once = modifiers.includes('once');
|
|
417
985
|
for (const h of handlers) {
|
|
418
986
|
let fn: EventListener;
|
|
419
987
|
|
|
@@ -456,8 +1024,29 @@ export class ViewController implements ViewControllerInterface {
|
|
|
456
1024
|
continue;
|
|
457
1025
|
}
|
|
458
1026
|
|
|
459
|
-
|
|
1027
|
+
if (modifiers.length) fn = this.wrapEventModifiers(fn, modifiers);
|
|
1028
|
+
element.addEventListener(event, fn, { signal: this.eventAbortController.signal, once });
|
|
1029
|
+
registered.push(fn);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (registered.length > 0) {
|
|
1033
|
+
let events = this.elementEventHandlers.get(element);
|
|
1034
|
+
if (!events) {
|
|
1035
|
+
events = new Map();
|
|
1036
|
+
this.elementEventHandlers.set(element, events);
|
|
1037
|
+
}
|
|
1038
|
+
events.set(event, registered);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
removeEventListener(element: HTMLElement, event: string): void {
|
|
1043
|
+
const events = this.elementEventHandlers.get(element);
|
|
1044
|
+
const listeners = events?.get(event) ?? [];
|
|
1045
|
+
for (const listener of listeners) {
|
|
1046
|
+
element.removeEventListener(event, listener);
|
|
460
1047
|
}
|
|
1048
|
+
events?.delete(event);
|
|
1049
|
+
if (events?.size === 0) this.elementEventHandlers.delete(element);
|
|
461
1050
|
}
|
|
462
1051
|
|
|
463
1052
|
// ─── Reactive Scheduling ────────────────────────────────────
|
|
@@ -477,6 +1066,16 @@ export class ViewController implements ViewControllerInterface {
|
|
|
477
1066
|
}
|
|
478
1067
|
}
|
|
479
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Có vùng reactive nào đang chờ re-render trong frame này không?
|
|
1071
|
+
*
|
|
1072
|
+
* StateManager dùng để phân biệt "factory ném vì vùng bọc nó sắp bị dựng
|
|
1073
|
+
* lại" với lỗi thật (xem StateManager.retryAfterReactiveFlush).
|
|
1074
|
+
*/
|
|
1075
|
+
hasPendingReactiveUpdate(): boolean {
|
|
1076
|
+
return this.pendingReactiveUpdates.size > 0;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
480
1079
|
private flushReactiveUpdates(): void {
|
|
481
1080
|
if (this._isDestroyed) return;
|
|
482
1081
|
this.hasScheduledUpdate = false;
|
|
@@ -491,31 +1090,63 @@ export class ViewController implements ViewControllerInterface {
|
|
|
491
1090
|
console.error(`[ViewController] Reactive update error in "${this.path}":`, e);
|
|
492
1091
|
}
|
|
493
1092
|
}
|
|
1093
|
+
this.flushAfterDom();
|
|
494
1094
|
}
|
|
495
1095
|
|
|
496
1096
|
|
|
1097
|
+
// Element & Block Creation Methods — called by compiled output to build the element tree directly, instead of returning HTML strings.
|
|
1098
|
+
|
|
1099
|
+
pushBlockAndSections(): void {
|
|
1100
|
+
// This method is called by compiled output after rendering blocks and sections, to ensure they are registered in the manager before any child views try to access them.
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* @section(name, ...) — registers content for a matching @yield(name) to
|
|
1107
|
+
* consume (possibly in a different controller — page declares, layout
|
|
1108
|
+
* yields, same cross-controller relationship as @block/@useBlock).
|
|
1109
|
+
*/
|
|
497
1110
|
section(name: string, config: { type: SectionItemType; contentType?: SectionContentType; stateKeys?: string[], [key: string]: any }, contentRenderFactory: SectionContentRenderer): SectionInterface {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1111
|
+
const existing = this.sections.get(name);
|
|
1112
|
+
if (existing) {
|
|
1113
|
+
// Re-render với closure mới (state/props đổi) — cập nhật factory tại chỗ,
|
|
1114
|
+
// SectionManager giữ nguyên đăng ký/subscription.
|
|
1115
|
+
existing.renderFactory = contentRenderFactory;
|
|
1116
|
+
existing.type = config.type;
|
|
1117
|
+
if (config.contentType) existing.contentType = config.contentType;
|
|
1118
|
+
existing.stateKeys = config.stateKeys ?? existing.stateKeys;
|
|
1119
|
+
return existing;
|
|
503
1120
|
}
|
|
504
1121
|
config.ctx = this;
|
|
505
1122
|
config.name = name;
|
|
506
1123
|
config.renderFactory = contentRenderFactory;
|
|
507
1124
|
const section = new Section(config as SectionConstruvtorArgs);
|
|
508
1125
|
this.sections.set(name, section);
|
|
1126
|
+
SectionManager.add(section);
|
|
509
1127
|
return section;
|
|
510
1128
|
}
|
|
511
1129
|
|
|
1130
|
+
/** `this.yieldContent(name, default)` — synchronous resolve, used inside attribute/prop binding factories. */
|
|
1131
|
+
yieldContent(name: string, defaultValue: any = null): any {
|
|
1132
|
+
return SectionManager.resolve(name, defaultValue);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Decode a raw SSR echo as RCDATA, preserving literal angle brackets. */
|
|
1136
|
+
decodeTextContent(value: string): string {
|
|
1137
|
+
const decoder = document.createElement('textarea');
|
|
1138
|
+
decoder.innerHTML = value;
|
|
1139
|
+
return decoder.textContent ?? '';
|
|
1140
|
+
}
|
|
1141
|
+
|
|
512
1142
|
|
|
513
1143
|
// template methods called by compiled output — these build the element tree directly
|
|
514
1144
|
|
|
515
1145
|
block(id: string | null, name: string, contentRenderFactory: BlockRenderFactory): BlockInterface {
|
|
516
1146
|
if (!id) {
|
|
517
|
-
id = `
|
|
1147
|
+
id = `block-${name}`;
|
|
518
1148
|
}
|
|
1149
|
+
id = `${this.viewId}-${id}`;
|
|
519
1150
|
const existing = this.blocks.get(id);
|
|
520
1151
|
const initMode = this.initMode;
|
|
521
1152
|
if (existing) {
|
|
@@ -533,18 +1164,22 @@ export class ViewController implements ViewControllerInterface {
|
|
|
533
1164
|
|
|
534
1165
|
blockOutlet(id: string | null = null, name: string, parentElement: HtmlInterface | null): BlockOutletInterface {
|
|
535
1166
|
const initMode = this.initMode;
|
|
536
|
-
if(!id) {
|
|
1167
|
+
if (!id) {
|
|
537
1168
|
id = `ob-${name}`;
|
|
538
1169
|
}
|
|
539
|
-
|
|
540
|
-
|
|
1170
|
+
id = `${this.viewId}-${id}`;
|
|
1171
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
1172
|
+
if (existing instanceof BlockOutlet && !(existing as any).__destroyed__) {
|
|
541
1173
|
existing.setParentElement(parentElement);
|
|
542
1174
|
existing.initMode = initMode;
|
|
1175
|
+
// Re-register: BlockManager có thể đã bị reset/clear giữa các lần mount
|
|
1176
|
+
// (addOutlet idempotent) — thiếu dòng này mountAll() sẽ không thấy outlet
|
|
1177
|
+
BlockManager.addOutlet(id, existing);
|
|
543
1178
|
return existing;
|
|
544
1179
|
}
|
|
545
1180
|
const outlet = new BlockOutlet({ ctx: this, name, id, parentElement, initMode });
|
|
546
|
-
this.
|
|
547
|
-
//
|
|
1181
|
+
this.registerElement(id, outlet);
|
|
1182
|
+
BlockManager.addOutlet(id, outlet); // đăng ký để mountAll tìm được outlet theo name
|
|
548
1183
|
return outlet;
|
|
549
1184
|
}
|
|
550
1185
|
|
|
@@ -559,12 +1194,12 @@ export class ViewController implements ViewControllerInterface {
|
|
|
559
1194
|
* Creates a Reactive region between markers that will hold the block content.
|
|
560
1195
|
* BlockManager.mountAll() later inserts the page view's block content here.
|
|
561
1196
|
*/
|
|
562
|
-
useBlock(id: string | null = null, name: string, parent: HtmlInterface):
|
|
1197
|
+
useBlock(id: string | null = null, name: string, parent: HtmlInterface): BlockOutletInterface {
|
|
563
1198
|
return this.blockOutlet(id, name, parent);
|
|
564
1199
|
}
|
|
565
1200
|
|
|
566
1201
|
yield(id: string, name: string, defaultValue: any = null, parentElement: HtmlInterface | null = null): YieldInterface {
|
|
567
|
-
const existing = this.elements.get(id);
|
|
1202
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
568
1203
|
if (existing instanceof YieldElement) {
|
|
569
1204
|
existing.setParentElement(parentElement);
|
|
570
1205
|
existing.defaultValue = defaultValue;
|
|
@@ -572,42 +1207,43 @@ export class ViewController implements ViewControllerInterface {
|
|
|
572
1207
|
}
|
|
573
1208
|
const yieldEl = new YieldElement({ ctx: this, name, initMode: this.initMode, id, defaultValue });
|
|
574
1209
|
yieldEl.setParentElement(parentElement);
|
|
575
|
-
this.
|
|
1210
|
+
this.registerElement(id, yieldEl);
|
|
1211
|
+
// yieldEl.id đã prefix viewId (globally unique) — dùng làm key trong SectionManager,
|
|
1212
|
+
// đăng ký để SectionManager.mountViewSections() tìm được outlet theo name.
|
|
1213
|
+
SectionManager.addYield(yieldEl.id, yieldEl);
|
|
576
1214
|
return yieldEl;
|
|
577
1215
|
}
|
|
578
1216
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const callingMethod = this.callingMethod;
|
|
585
|
-
let key: 'preloadElement' | 'mainElement' = callingMethod === 'prerender' ? 'preloadElement' : 'mainElement';
|
|
586
|
-
let wrapper = this[key];
|
|
587
|
-
if (!wrapper) {
|
|
1217
|
+
wrapper(factory: SaoChildrenFactory): WrapperInterface {
|
|
1218
|
+
const which: 'render' | 'prerender' = this.callingMethod === 'prerender' ? 'prerender' : 'render';
|
|
1219
|
+
const key: 'preloadElement' | 'mainElement' = which === 'prerender' ? 'preloadElement' : 'mainElement';
|
|
1220
|
+
let wrapper = this.wrapperInstances[which];
|
|
1221
|
+
if (!wrapper || (wrapper as any).__destroyed__) {
|
|
588
1222
|
wrapper = new Wrapper({ ctx: this, initMode: this.initMode, parentElement: this.parentElement, childrenFactory: factory });
|
|
589
|
-
this[
|
|
590
|
-
}else{
|
|
1223
|
+
this.wrapperInstances[which] = wrapper;
|
|
1224
|
+
} else {
|
|
591
1225
|
wrapper.setChildrenFactory(factory);
|
|
592
1226
|
}
|
|
1227
|
+
this[key] = wrapper;
|
|
593
1228
|
return wrapper;
|
|
594
1229
|
}
|
|
595
1230
|
|
|
596
1231
|
|
|
597
|
-
fragment(id: string | null = null, parentElement: HtmlInterface | null, childrenFactory:
|
|
1232
|
+
fragment(id: string | null = null, parentElement: HtmlInterface | null, childrenFactory: SaoChildrenFactory): FragmentInterface {
|
|
1233
|
+
childrenFactory = this.scopeFactory(childrenFactory);
|
|
598
1234
|
if (!id) {
|
|
599
1235
|
id = `fr-${generateUUID(5)}`;
|
|
600
1236
|
}
|
|
601
|
-
const existing = this.
|
|
602
|
-
if (existing
|
|
603
|
-
if(childrenFactory) {
|
|
1237
|
+
const existing = this.aliveFromRegistry(id, Fragment);
|
|
1238
|
+
if (existing) {
|
|
1239
|
+
if (childrenFactory) {
|
|
604
1240
|
existing.setChildrenFactory(childrenFactory);
|
|
605
1241
|
}
|
|
606
1242
|
return existing;
|
|
607
1243
|
}
|
|
608
1244
|
const initMode = this.initMode;
|
|
609
|
-
const fragment = new Fragment({ctx: this, id, initMode, parentElement, childrenFactory });
|
|
610
|
-
this.
|
|
1245
|
+
const fragment = new Fragment({ ctx: this, id, initMode, parentElement, childrenFactory });
|
|
1246
|
+
this.registerElement(id, fragment);
|
|
611
1247
|
return fragment;
|
|
612
1248
|
}
|
|
613
1249
|
|
|
@@ -617,31 +1253,37 @@ export class ViewController implements ViewControllerInterface {
|
|
|
617
1253
|
* These methods are called by the compiled output for loops, conditionals, and other directives.
|
|
618
1254
|
*/
|
|
619
1255
|
|
|
620
|
-
html(id: string | null = null, tagName: string, parentElement: HtmlInterface | null, config: any, childrenFactory?:
|
|
1256
|
+
html(id: string | null = null, tagName: string, parentElement: HtmlInterface | null, config: any, childrenFactory?: SaoChildrenFactory): SaoNodeInterface {
|
|
1257
|
+
if (childrenFactory) childrenFactory = this.scopeFactory(childrenFactory);
|
|
621
1258
|
if (!id) {
|
|
622
1259
|
id = `el-${tagName}-${generateUUID(5)}`;
|
|
623
1260
|
}
|
|
624
|
-
const existing = this.
|
|
625
|
-
if (existing
|
|
1261
|
+
const existing = this.aliveFromRegistry(id, Html);
|
|
1262
|
+
if (existing) {
|
|
626
1263
|
existing.updateConfig(config);
|
|
627
|
-
if(childrenFactory) {
|
|
1264
|
+
if (childrenFactory) {
|
|
628
1265
|
existing.setChildrenFactory(childrenFactory);
|
|
629
1266
|
}
|
|
630
1267
|
return existing;
|
|
631
1268
|
}
|
|
632
1269
|
const element = new Html({ ctx: this, tagName, id, parentElement, initMode: this.initMode, config, childrenFactory });
|
|
633
|
-
this.
|
|
1270
|
+
this.registerElement(id, element);
|
|
634
1271
|
return element;
|
|
635
1272
|
}
|
|
636
1273
|
|
|
637
1274
|
|
|
638
1275
|
reactive(id: string | null, type: string, parentReactive: ReactiveInterface | null, parentElement: HtmlInterface | null, stateKeys: string[], childrenFactory: ReactiveChildrenFactory): ReactiveInterface {
|
|
1276
|
+
childrenFactory = this.scopeFactory(childrenFactory);
|
|
639
1277
|
// check
|
|
640
1278
|
if (!id) {
|
|
641
1279
|
id = `r-${type}-${generateUUID(5)}`;
|
|
642
1280
|
}
|
|
643
|
-
|
|
644
|
-
|
|
1281
|
+
// Prefix viewId để khớp marker id server phát ra (RUNTIME_CONTRACT §5):
|
|
1282
|
+
// compiler emit cùng hash cho JS + Blade, server hoá thành `${viewId}-${hash}`.
|
|
1283
|
+
// Giống output()/block()/blockOutlet() — Reactive trước đây bị thiếu bước này.
|
|
1284
|
+
id = `${this.viewId}-${id}`;
|
|
1285
|
+
const existing = this.aliveFromRegistry(id, Reactive);
|
|
1286
|
+
if (existing) {
|
|
645
1287
|
existing.setChildrenFactory(childrenFactory);
|
|
646
1288
|
existing.setStateKeys(stateKeys);
|
|
647
1289
|
return existing;
|
|
@@ -653,18 +1295,19 @@ export class ViewController implements ViewControllerInterface {
|
|
|
653
1295
|
parentReactive,
|
|
654
1296
|
parentElement,
|
|
655
1297
|
stateKeys,
|
|
656
|
-
childrenFactory
|
|
1298
|
+
childrenFactory,
|
|
1299
|
+
initMode: this.initMode,
|
|
657
1300
|
});
|
|
658
|
-
this.
|
|
1301
|
+
this.registerElement(id, reactive);
|
|
659
1302
|
return reactive;
|
|
660
1303
|
}
|
|
661
1304
|
|
|
662
1305
|
output(id: string | null, parent: HtmlInterface | null, isEscapeHTML: boolean = true, stateKeys: string[] = [], contentFactory: () => string = () => ''): OutputInterface {
|
|
663
|
-
if(!id) {
|
|
1306
|
+
if (!id) {
|
|
664
1307
|
id = `o-${generateUUID(5)}`;
|
|
665
1308
|
}
|
|
666
|
-
const existing = this.
|
|
667
|
-
if (existing
|
|
1309
|
+
const existing = this.aliveFromRegistry(id, Output);
|
|
1310
|
+
if (existing) {
|
|
668
1311
|
existing.setStateKeys(stateKeys);
|
|
669
1312
|
existing.contentFactory = contentFactory;
|
|
670
1313
|
return existing;
|
|
@@ -676,13 +1319,93 @@ export class ViewController implements ViewControllerInterface {
|
|
|
676
1319
|
stateKeys,
|
|
677
1320
|
isEscapeHTML,
|
|
678
1321
|
contentFactory,
|
|
1322
|
+
initMode: this.initMode,
|
|
679
1323
|
});
|
|
680
|
-
this.
|
|
1324
|
+
this.registerElement(id, output);
|
|
681
1325
|
return output;
|
|
682
1326
|
}
|
|
683
1327
|
|
|
684
|
-
|
|
685
|
-
|
|
1328
|
+
/**
|
|
1329
|
+
* FIX(baseline#2): trả về TextElement (có saoType) thay vì raw Text node.
|
|
1330
|
+
* Raw Text node bị mountElementList/Reactive.render bỏ qua → text tĩnh biến mất.
|
|
1331
|
+
*/
|
|
1332
|
+
text(text: string): TextInterface {
|
|
1333
|
+
return new TextElement({ ctx: this, stateKeys: [], generateText: () => text });
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Ghi element vào registry + nhớ key để destroy() gỡ lại được.
|
|
1338
|
+
* Key là id THÔ; `element.id` phần lớn đã prefix viewId (`${viewId}-${id}`)
|
|
1339
|
+
* nên element KHÔNG tự suy ra key được — WeakMap giữ ánh xạ, không giữ
|
|
1340
|
+
* element sống.
|
|
1341
|
+
*/
|
|
1342
|
+
private registerElement<T extends object>(id: string, el: T): T {
|
|
1343
|
+
this.elements.set(id, el as any);
|
|
1344
|
+
this.elementKeys.set(el, id);
|
|
1345
|
+
if (this.foreachRegistry) {
|
|
1346
|
+
this.foreachRegistry.set(id, el);
|
|
1347
|
+
this.elementRegistries.set(el, this.foreachRegistry);
|
|
1348
|
+
}
|
|
1349
|
+
return el;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Element tự gỡ khỏi registry trong destroy() — nếu không, `elements` chỉ
|
|
1354
|
+
* co lại lúc destroy view, còn id sinh theo item (`{hash}-${item.id}`) thì
|
|
1355
|
+
* mỗi trang/mỗi lần refresh lại thêm một corpse vĩnh viễn (đo được:
|
|
1356
|
+
* 32 → 182 entry sau 6 trang trong khi DOM vẫn 10 item).
|
|
1357
|
+
*
|
|
1358
|
+
* Guard `=== el` là bắt buộc: pass mới đã ghi element MỚI vào cùng id TRƯỚC
|
|
1359
|
+
* khi prunePass destroy element cũ — xoá vô điều kiện sẽ gỡ nhầm bản mới.
|
|
1360
|
+
*/
|
|
1361
|
+
releaseElement(el: object): void {
|
|
1362
|
+
const id = this.elementKeys.get(el);
|
|
1363
|
+
if (id === undefined) return;
|
|
1364
|
+
this.elementKeys.delete(el);
|
|
1365
|
+
const registry = this.elementRegistries.get(el);
|
|
1366
|
+
if (registry?.get(id) === el) registry.delete(id);
|
|
1367
|
+
this.elementRegistries.delete(el);
|
|
1368
|
+
if (this.elements.get(id) === el) this.elements.delete(id);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Registry guard: element đã destroy không được reuse — trả về corpse
|
|
1373
|
+
* (events đã abort, markers đã gỡ) gây render rỗng / stale closure.
|
|
1374
|
+
* Xem docs/RUNTIME_CONTRACT.md §2.
|
|
1375
|
+
*/
|
|
1376
|
+
private aliveFromRegistry<T>(id: string, ctor: abstract new (...args: any[]) => T): T | null {
|
|
1377
|
+
if (this._foreachSkipRegistry) return null;
|
|
1378
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
1379
|
+
if (existing instanceof ctor && !(existing as any).__destroyed__) {
|
|
1380
|
+
return existing as T;
|
|
1381
|
+
}
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Deterministic fallback counter for the (contract-violating) missing-id path. */
|
|
1386
|
+
private _missingIncludeIdCounter = 0;
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Resolve the hydrate id for an @include component.
|
|
1390
|
+
*
|
|
1391
|
+
* The compiler ALWAYS emits a deterministic id (md5[:8] of the position-based
|
|
1392
|
+
* base id) so the client marker `s:c:{viewId}-{id}` matches the server-rendered
|
|
1393
|
+
* one. A missing id therefore means a compiler/runtime contract violation — and
|
|
1394
|
+
* in HYDRATE mode it guarantees a marker mismatch (claimSSRMarkers finds nothing
|
|
1395
|
+
* → silent CSR re-render / duplicated DOM).
|
|
1396
|
+
*
|
|
1397
|
+
* Never invent a RANDOM id here: a random id also makes every `elements.get(id)`
|
|
1398
|
+
* lookup miss, so the component is recreated on each render (cache broken). We
|
|
1399
|
+
* surface the violation loudly and fall back to a render-stable deterministic id
|
|
1400
|
+
* so behaviour stays idempotent even in the broken case.
|
|
1401
|
+
*/
|
|
1402
|
+
private resolveIncludeId(id: string | null, kind: string, path: string): string {
|
|
1403
|
+
if (id) return id;
|
|
1404
|
+
console.error(
|
|
1405
|
+
`[Saola] ${kind}(): missing hydrate id for view "${path}". The compiler must ` +
|
|
1406
|
+
`pass a deterministic id — marker sync with the server is broken for this component.`
|
|
1407
|
+
);
|
|
1408
|
+
return `cpn-missing-${this._missingIncludeIdCounter++}`;
|
|
686
1409
|
}
|
|
687
1410
|
|
|
688
1411
|
include(
|
|
@@ -690,89 +1413,93 @@ export class ViewController implements ViewControllerInterface {
|
|
|
690
1413
|
path: string = '',
|
|
691
1414
|
parentElement: HtmlInterface | null,
|
|
692
1415
|
stateKeys: string[],
|
|
693
|
-
dataFactory: (parentElement: HtmlInterface | null) => Record<string, any
|
|
1416
|
+
dataFactory: (parentElement: HtmlInterface | null) => Record<string, any>,
|
|
1417
|
+
listeners: Record<string, (...args: any[]) => any> = {}
|
|
694
1418
|
): Component {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const existing = this.elements.get(id);
|
|
699
|
-
if (existing instanceof Component) {
|
|
1419
|
+
id = this.resolveIncludeId(id, 'include', path);
|
|
1420
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1421
|
+
if (existing) {
|
|
700
1422
|
existing.setDataFactory(dataFactory);
|
|
701
|
-
|
|
1423
|
+
existing.setListeners(listeners);
|
|
1424
|
+
if (stateKeys) {
|
|
702
1425
|
existing.setStateKeys(stateKeys);
|
|
703
1426
|
}
|
|
704
1427
|
return existing;
|
|
705
1428
|
}
|
|
706
1429
|
let component = new Component({
|
|
707
1430
|
ctx: this,
|
|
708
|
-
id
|
|
1431
|
+
id,
|
|
709
1432
|
stateKeys,
|
|
710
1433
|
parent: parentElement,
|
|
711
1434
|
dataFactory,
|
|
712
1435
|
path,
|
|
713
1436
|
type: 'default',
|
|
1437
|
+
initMode: this.initMode,
|
|
1438
|
+
listeners,
|
|
714
1439
|
});
|
|
715
|
-
this.
|
|
1440
|
+
this.registerElement(id, component);
|
|
716
1441
|
|
|
717
1442
|
return component;
|
|
718
1443
|
}
|
|
719
1444
|
|
|
720
|
-
includeIf(id: string | null = null, path: string, parentElement: HtmlInterface | null, stateKeys: string[], dataFactory: (parentElement: HtmlInterface | null) => Record<string, any>): Component {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const existing = this.elements.get(id);
|
|
725
|
-
if (existing instanceof Component) {
|
|
1445
|
+
includeIf(id: string | null = null, path: string, parentElement: HtmlInterface | null, stateKeys: string[], dataFactory: (parentElement: HtmlInterface | null) => Record<string, any>, listeners: Record<string, (...args: any[]) => any> = {}): Component {
|
|
1446
|
+
id = this.resolveIncludeId(id, 'includeIf', path);
|
|
1447
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1448
|
+
if (existing) {
|
|
726
1449
|
existing.setDataFactory(dataFactory);
|
|
727
|
-
|
|
1450
|
+
existing.setListeners(listeners);
|
|
1451
|
+
if (stateKeys) {
|
|
728
1452
|
existing.setStateKeys(stateKeys);
|
|
729
1453
|
}
|
|
730
1454
|
return existing;
|
|
731
1455
|
}
|
|
732
1456
|
let component = new Component({
|
|
733
1457
|
ctx: this,
|
|
734
|
-
id
|
|
1458
|
+
id,
|
|
735
1459
|
stateKeys,
|
|
736
1460
|
parent: parentElement,
|
|
737
1461
|
dataFactory,
|
|
738
1462
|
path,
|
|
739
1463
|
type: 'if',
|
|
1464
|
+
initMode: this.initMode,
|
|
1465
|
+
listeners,
|
|
740
1466
|
});
|
|
741
|
-
this.
|
|
1467
|
+
this.registerElement(id, component);
|
|
742
1468
|
return component;
|
|
743
1469
|
}
|
|
744
1470
|
|
|
745
|
-
includeWhen(id: string | null, condition: { stateKeys: string[], checker: () => any }, path: string, parentElement: HtmlInterface | null, stateKeys: string[], dataFactory: (parentElement: HtmlInterface | null) => Record<string, any>): Component {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const existing = this.elements.get(id);
|
|
750
|
-
if (existing instanceof Component) {
|
|
1471
|
+
includeWhen(id: string | null, condition: { stateKeys: string[], checker: () => any }, path: string, parentElement: HtmlInterface | null, stateKeys: string[], dataFactory: (parentElement: HtmlInterface | null) => Record<string, any>, listeners: Record<string, (...args: any[]) => any> = {}): Component {
|
|
1472
|
+
id = this.resolveIncludeId(id, 'includeWhen', path);
|
|
1473
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1474
|
+
if (existing) {
|
|
751
1475
|
existing.setDataFactory(dataFactory);
|
|
752
1476
|
existing.setCondition(condition);
|
|
753
|
-
if(stateKeys) {
|
|
1477
|
+
if (stateKeys) {
|
|
754
1478
|
existing.setStateKeys(stateKeys);
|
|
755
1479
|
}
|
|
756
1480
|
return existing;
|
|
757
1481
|
}
|
|
758
1482
|
let component = new Component({
|
|
759
1483
|
ctx: this,
|
|
760
|
-
id
|
|
1484
|
+
id,
|
|
761
1485
|
stateKeys,
|
|
762
1486
|
parent: parentElement,
|
|
763
1487
|
dataFactory,
|
|
764
1488
|
path,
|
|
765
1489
|
type: 'when',
|
|
766
1490
|
condition,
|
|
1491
|
+
initMode: this.initMode,
|
|
1492
|
+
listeners,
|
|
767
1493
|
});
|
|
768
|
-
this.
|
|
1494
|
+
this.registerElement(id, component);
|
|
769
1495
|
return component;
|
|
770
1496
|
}
|
|
771
1497
|
|
|
772
1498
|
extendView(path: string, data: Record<string, any> = {}): ViewInterface | null {
|
|
773
1499
|
this.superViewPath = path;
|
|
774
|
-
|
|
775
|
-
|
|
1500
|
+
// Sync: render() trả superView ngay (xem ViewManager.resolveViewSync).
|
|
1501
|
+
const superView: ViewInterface = this.App.View?.resolveViewSync(path, data, true);
|
|
1502
|
+
if (superView) {
|
|
776
1503
|
this.setSuperView(superView?.__ctrl__);
|
|
777
1504
|
return superView;
|
|
778
1505
|
}
|
|
@@ -809,60 +1536,127 @@ export class ViewController implements ViewControllerInterface {
|
|
|
809
1536
|
/**
|
|
810
1537
|
* @foreach directive — iterate over array or object.
|
|
811
1538
|
* Returns array of children (not HTML string like the old system).
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1539
|
+
*
|
|
1540
|
+
* # Cache-aware re-render (Phase 5 + 5b)
|
|
1541
|
+
* Khi `_currentForeachCache` được set (bởi Reactive), __foreach claim slot
|
|
1542
|
+
* cho từng item:
|
|
1543
|
+
* - Hit (key khớp + item ref giống) → reuse elements cũ (không recreate DOM)
|
|
1544
|
+
* - Miss → gọi callback → tạo elements mới → store vào cache
|
|
1545
|
+
*
|
|
1546
|
+
* Cache key:
|
|
1547
|
+
* - `keyFn` (compiler emit từ @key(expr)) → field keying (`item.id`)
|
|
1548
|
+
* - không có → object reference của item (identity keying)
|
|
1549
|
+
* Duplicate keys/primitive items phân biệt bằng occurrence (ForeachSlotCache).
|
|
1550
|
+
* Ref đổi nhưng key trùng → recreate (closure đóng gói item cũ) — slot cũ
|
|
1551
|
+
* được Reactive.prunePass destroy.
|
|
1552
|
+
*
|
|
1553
|
+
* @example Compiled output (@key(item.id)):
|
|
814
1554
|
* ctrl.__foreach(items, (item, key, index, loop) => [
|
|
815
|
-
*
|
|
816
|
-
* ])
|
|
1555
|
+
* this.html(`id-${item.id}`, 'div', p, {}, () => [this.text(item.name)])
|
|
1556
|
+
* ], (item) => item.id)
|
|
1557
|
+
*/
|
|
1558
|
+
/**
|
|
1559
|
+
* @children / {{ $children }} — materialize slot content from the parent
|
|
1560
|
+
* include only when render traversal reaches ChildrenNode (compiler emit:
|
|
1561
|
+
* `...this.__children(__ONE_CHILDREN_CONTENT__, parentElement)`).
|
|
1562
|
+
*
|
|
1563
|
+
* content có 2 dạng (xem COMPILER _gen_children_slot):
|
|
1564
|
+
* - function `(parentElement) => elements` — element factory từ
|
|
1565
|
+
* @importInclude/custom tag phía parent. Factory đóng gói `this` của
|
|
1566
|
+
* PARENT ctrl → elements thuộc parent scope (state/registry parent),
|
|
1567
|
+
* giống React children.
|
|
1568
|
+
* - string — SSR data hoặc default '' → render text tĩnh (rỗng → []).
|
|
817
1569
|
*/
|
|
1570
|
+
__children(content: SaoChildrenSlotContent, parentElement: HtmlInterface | null): SaoChildrenFactoryOutput {
|
|
1571
|
+
if (typeof content === 'function') {
|
|
1572
|
+
const out = content(parentElement);
|
|
1573
|
+
if (out === null || out === undefined) return [];
|
|
1574
|
+
return Array.isArray(out) ? out : [out];
|
|
1575
|
+
}
|
|
1576
|
+
if (content === null || content === undefined || content === '') return [];
|
|
1577
|
+
return [this.text(String(content))];
|
|
1578
|
+
}
|
|
1579
|
+
|
|
818
1580
|
__foreach<T>(
|
|
819
1581
|
list: T[] | Record<string, T>,
|
|
820
|
-
callback: (item: T, key: string, index: number, loop:
|
|
1582
|
+
callback: (item: T, key: string, index: number, loop: LoopContextInterface, identity: number) => any,
|
|
1583
|
+
keyFn?: (item: T, index: number) => any,
|
|
1584
|
+
reconcile = false,
|
|
1585
|
+
scopeId?: string,
|
|
821
1586
|
): any[] {
|
|
822
1587
|
if (!list || typeof list !== 'object') return [];
|
|
823
|
-
|
|
1588
|
+
let cache = Array.isArray(list) || reconcile ? this._currentForeachCache : null;
|
|
1589
|
+
let ownsPass = false;
|
|
1590
|
+
if (!cache && reconcile && scopeId && this.foreachRegistry) {
|
|
1591
|
+
let caches = this.inlineForeachCaches.get(this.foreachRegistry);
|
|
1592
|
+
if (!caches) this.inlineForeachCaches.set(this.foreachRegistry, caches = new Map());
|
|
1593
|
+
cache = caches.get(scopeId) ?? null;
|
|
1594
|
+
if (!cache) caches.set(scopeId, cache = new ForeachSlotCache());
|
|
1595
|
+
cache.beginPass();
|
|
1596
|
+
ownsPass = true;
|
|
1597
|
+
}
|
|
824
1598
|
const result: any[] = [];
|
|
1599
|
+
const keys = Array.isArray(list)
|
|
1600
|
+
? Object.keys(list).filter(key => String(Number(key)) === key && Number(key) >= 0 && Number(key) < list.length)
|
|
1601
|
+
: Object.keys(list);
|
|
1602
|
+
const loopCtx = this.__setLoopContext(Array.isArray(list) ? list.length : keys.length);
|
|
1603
|
+
loopCtx.setType('increment');
|
|
825
1604
|
try {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
1605
|
+
keys.forEach((key, index) => {
|
|
1606
|
+
// Sparse arrays retain their actual index and loop count.
|
|
1607
|
+
const loopIndex = Array.isArray(list) ? Number(key) : index;
|
|
1608
|
+
const item = (list as any)[key] as T;
|
|
1609
|
+
loopCtx.setCurrentTimes(loopIndex);
|
|
1610
|
+
const cacheKey = keyFn ? keyFn(item, loopIndex) : item;
|
|
1611
|
+
const claim = cache?.claim(cacheKey, item, reconcile);
|
|
1612
|
+
const slot = claim?.slot;
|
|
1613
|
+
if (slot && !reconcile) {
|
|
1614
|
+
result.push(...slot.elements);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const registry = reconcile ? slot?.registry ?? new Map<string, any>() : null;
|
|
1618
|
+
const identity = slot?.identity ?? (cache && reconcile ? cache.allocateIdentity(loopIndex) : loopIndex);
|
|
1619
|
+
const previousCache = this._currentForeachCache;
|
|
1620
|
+
const previousSkip = this._foreachSkipRegistry;
|
|
1621
|
+
this._currentForeachCache = null; // nested inline loops own no outer slots
|
|
1622
|
+
if (cache && !reconcile) this._foreachSkipRegistry = true;
|
|
1623
|
+
let output: any;
|
|
1624
|
+
try {
|
|
1625
|
+
const run = () => callback(item, key, loopIndex, loopCtx.snapshot(), identity);
|
|
1626
|
+
output = registry ? this.inForeachScope(registry, run) : run();
|
|
1627
|
+
} finally {
|
|
1628
|
+
this._currentForeachCache = previousCache;
|
|
1629
|
+
this._foreachSkipRegistry = previousSkip;
|
|
1630
|
+
}
|
|
1631
|
+
let elements = output == null ? [] : Array.isArray(output) ? output : [output];
|
|
1632
|
+
if (slot && reconcile) elements = reuseStaticText(slot.elements, elements);
|
|
1633
|
+
result.push(...elements);
|
|
1634
|
+
if (cache && claim) {
|
|
1635
|
+
if (slot && reconcile) {
|
|
1636
|
+
slot.item = item;
|
|
1637
|
+
slot.identity = identity;
|
|
1638
|
+
slot.elements = elements;
|
|
1639
|
+
for (const el of elements) cache.refreshedElements.add(el);
|
|
1640
|
+
} else cache.store(cacheKey, claim.occ, item, elements, registry ?? undefined).identity = identity;
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
if (ownsPass) cache!.prunePass(slot => {
|
|
1644
|
+
for (const el of slot.elements) el.destroy?.();
|
|
1645
|
+
});
|
|
852
1646
|
} finally {
|
|
853
1647
|
this.__resetLoopContext();
|
|
854
1648
|
}
|
|
855
1649
|
return result;
|
|
856
1650
|
}
|
|
857
1651
|
|
|
858
|
-
__forelse<T>(list: T[], callback: (item: T, key: string, index: number, loop:
|
|
1652
|
+
__forelse<T>(list: T[], callback: (item: T, key: string, index: number, loop: LoopContextInterface) => any, emptyCallback: () => any = () => []): any[] {
|
|
859
1653
|
if (this.App.Helper.count(list) === 0) {
|
|
860
1654
|
return emptyCallback();
|
|
861
1655
|
}
|
|
862
1656
|
return this.__foreach(list, callback);
|
|
863
1657
|
}
|
|
864
1658
|
|
|
865
|
-
__each<T>(list: T[], callback: (item: T, key: string, index: number, loop:
|
|
1659
|
+
__each<T>(list: T[], callback: (item: T, key: string, index: number, loop: LoopContextInterface) => any): any[] {
|
|
866
1660
|
return this.__foreach(list, callback);
|
|
867
1661
|
}
|
|
868
1662
|
|
|
@@ -911,6 +1705,91 @@ export class ViewController implements ViewControllerInterface {
|
|
|
911
1705
|
}
|
|
912
1706
|
|
|
913
1707
|
|
|
1708
|
+
// ─── Directive Binding Helpers ──────────────────────────────
|
|
1709
|
+
//
|
|
1710
|
+
// Compiler pre-processes @style/@class directives thành template literal
|
|
1711
|
+
// calls trước khi tạo element config.
|
|
1712
|
+
// Ví dụ: @style([...]) → ${this.__styleBinding([...], [...])}
|
|
1713
|
+
// @class([...]) → ${this.__classBinding([...])} (legacy path)
|
|
1714
|
+
//
|
|
1715
|
+
// Các method này được Html._applyAttr() gọi qua factory khi render và khi
|
|
1716
|
+
// state thay đổi (Html đã subscribe stateKeys từ compiled config).
|
|
1717
|
+
//
|
|
1718
|
+
// `__showBinding` từng ở đây, cho `@show`. Compiler đã gỡ `@show`/`@hide`
|
|
1719
|
+
// (chúng hỏng ở CẢ HAI nhánh và ra hai cây DOM khác nhau) nên không còn ai
|
|
1720
|
+
// gọi tới — xem docs/SAO_ELEMENT_DIRECTIVES_RFC.md §10.1.
|
|
1721
|
+
// ────────────────────────────────────────────────────────────
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* __styleBinding — tính inline CSS style string cho @style directive.
|
|
1725
|
+
*
|
|
1726
|
+
* Compiler emit (pre-process trước AST):
|
|
1727
|
+
* @style(['color' => $textColor, 'font-size' => $fontSize])
|
|
1728
|
+
* → ${this.__styleBinding(['textColor', 'fontSize'], [['color', textColor], ['font-size', fontSize]])}
|
|
1729
|
+
*
|
|
1730
|
+
* Hành vi:
|
|
1731
|
+
* - Lọc bỏ entries có value null / undefined / false / '' (không set prop đó)
|
|
1732
|
+
* - Join thành "prop: value; prop: value" string
|
|
1733
|
+
*
|
|
1734
|
+
* @param _stateKeys - Danh sách state keys (đã encode trong config, không dùng ở đây)
|
|
1735
|
+
* @param styles - Mảng [cssProperty, value] pairs
|
|
1736
|
+
*
|
|
1737
|
+
* @example
|
|
1738
|
+
* __styleBinding([], [['color', 'red'], ['font-size', null], ['display', 'block']])
|
|
1739
|
+
* // → "color: red; display: block"
|
|
1740
|
+
*/
|
|
1741
|
+
__styleBinding(_stateKeys: string[], styles: [string, any][]): string {
|
|
1742
|
+
if (!Array.isArray(styles)) return '';
|
|
1743
|
+
return styles
|
|
1744
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== false && value !== '')
|
|
1745
|
+
.map(([prop, value]) => `${prop}: ${String(value)}`)
|
|
1746
|
+
.join('; ');
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* __classBinding — tính CSS class string cho @class binding (legacy template path).
|
|
1751
|
+
*
|
|
1752
|
+
* Chỉ được gọi bởi OLD template_processor (flat compiler) fallback path.
|
|
1753
|
+
* New AST path (RenderGenerator) emit class config trực tiếp vào options.classes[]
|
|
1754
|
+
* → Html.initializeClasses() xử lý, KHÔNG dùng __classBinding.
|
|
1755
|
+
*
|
|
1756
|
+
* Format input (từ class_binding_handler.py):
|
|
1757
|
+
* [{ type: 'static', value: 'foo' },
|
|
1758
|
+
* { type: 'binding', value: 'is-active', states: ['isActive'], checker: () => isActive }]
|
|
1759
|
+
*
|
|
1760
|
+
* @param configs - Mảng class config objects
|
|
1761
|
+
* @returns Space-joined class string
|
|
1762
|
+
*
|
|
1763
|
+
* @example
|
|
1764
|
+
* __classBinding([
|
|
1765
|
+
* { type: 'static', value: 'btn' },
|
|
1766
|
+
* { type: 'binding', value: 'btn-primary', checker: () => isPrimary }
|
|
1767
|
+
* ])
|
|
1768
|
+
* // isPrimary = true → "btn btn-primary"
|
|
1769
|
+
* // isPrimary = false → "btn"
|
|
1770
|
+
*/
|
|
1771
|
+
__classBinding(configs: Array<{
|
|
1772
|
+
type: 'static' | 'binding';
|
|
1773
|
+
value: string;
|
|
1774
|
+
states?: string[];
|
|
1775
|
+
checker?: () => any;
|
|
1776
|
+
}>): string {
|
|
1777
|
+
if (!Array.isArray(configs)) return '';
|
|
1778
|
+
const classes: string[] = [];
|
|
1779
|
+
for (const config of configs) {
|
|
1780
|
+
if (!config?.value) continue;
|
|
1781
|
+
if (config.type === 'static') {
|
|
1782
|
+
classes.push(config.value);
|
|
1783
|
+
} else if (config.type === 'binding') {
|
|
1784
|
+
// Thực thi checker để quyết định thêm class không
|
|
1785
|
+
if (typeof config.checker === 'function' && config.checker()) {
|
|
1786
|
+
classes.push(config.value);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return classes.join(' ');
|
|
1791
|
+
}
|
|
1792
|
+
|
|
914
1793
|
// mounting
|
|
915
1794
|
|
|
916
1795
|
|
|
@@ -945,11 +1824,16 @@ export class ViewController implements ViewControllerInterface {
|
|
|
945
1824
|
setIsSuperView(isSuper: boolean): void {
|
|
946
1825
|
this.isSuperView = isSuper;
|
|
947
1826
|
}
|
|
948
|
-
setParent(parent: ViewControllerInterface): void {
|
|
1827
|
+
setParent(parent: ViewControllerInterface | null): void {
|
|
949
1828
|
this.parent = parent;
|
|
950
1829
|
}
|
|
951
1830
|
addChild(child: ViewControllerInterface): void {
|
|
952
|
-
this.children.push(child);
|
|
1831
|
+
if (!this.children.includes(child)) this.children.push(child);
|
|
1832
|
+
}
|
|
1833
|
+
removeChild(child: ViewControllerInterface): void {
|
|
1834
|
+
const index = this.children.indexOf(child);
|
|
1835
|
+
if (index >= 0) this.children.splice(index, 1);
|
|
1836
|
+
if (child.parent === this) child.setParent(null);
|
|
953
1837
|
}
|
|
954
1838
|
|
|
955
1839
|
|
|
@@ -958,7 +1842,7 @@ export class ViewController implements ViewControllerInterface {
|
|
|
958
1842
|
* Called by ViewManager after creating the view and its super view(s).
|
|
959
1843
|
*/
|
|
960
1844
|
setChainFromOrigin(): void {
|
|
961
|
-
if(this.superView){
|
|
1845
|
+
if (this.superView) {
|
|
962
1846
|
this.superView.setOriginView(this);
|
|
963
1847
|
this.superView.setChainFromOrigin();
|
|
964
1848
|
}
|
|
@@ -968,7 +1852,7 @@ export class ViewController implements ViewControllerInterface {
|
|
|
968
1852
|
* When a view is destroyed, it should eject itself from the origin chain to prevent memory leaks.
|
|
969
1853
|
*/
|
|
970
1854
|
ejectOriginChain(): void {
|
|
971
|
-
if(this.originView){
|
|
1855
|
+
if (this.originView) {
|
|
972
1856
|
const origin = this.originView;
|
|
973
1857
|
this.originView = null;
|
|
974
1858
|
origin.ejectOriginChain();
|