@saolabs/client 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -195
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/plugins.d.ts +2 -2
- package/dist/plugins.js +2 -2
- package/dist/src/core/app/Application.d.ts +17 -15
- package/dist/src/core/app/Application.d.ts.map +1 -1
- package/dist/src/core/app/Application.js +41 -18
- package/dist/src/core/app/Application.js.map +1 -1
- package/dist/src/core/bootstrap/app.d.ts +1 -1
- package/dist/src/core/bootstrap/app.d.ts.map +1 -1
- package/dist/src/core/bootstrap/app.js +74 -4
- package/dist/src/core/bootstrap/app.js.map +1 -1
- package/dist/src/core/bootstrap/bundle.d.ts +41 -0
- package/dist/src/core/bootstrap/bundle.d.ts.map +1 -0
- package/dist/src/core/bootstrap/bundle.js +98 -0
- package/dist/src/core/bootstrap/bundle.js.map +1 -0
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.js +3 -3
- package/dist/src/core/bootstrap/providers/ApiServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js +11 -8
- package/dist/src/core/bootstrap/providers/CoreServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts +3 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.js +8 -3
- package/dist/src/core/bootstrap/providers/HelperServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts +2 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.js +14 -4
- package/dist/src/core/bootstrap/providers/RouteServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts +4 -4
- package/dist/src/core/bootstrap/providers/ServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ServiceProvider.js +5 -5
- package/dist/src/core/bootstrap/providers/ServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.js +3 -3
- package/dist/src/core/bootstrap/providers/ViewServiceProvider.js.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/default-providers.js +44 -11
- package/dist/src/core/bootstrap/providers/default-providers.js.map +1 -1
- package/dist/src/core/bootstrap/providers/index.d.ts +9 -9
- package/dist/src/core/bootstrap/providers/index.d.ts.map +1 -1
- package/dist/src/core/bootstrap/providers/index.js +8 -8
- package/dist/src/core/bootstrap/providers/index.js.map +1 -1
- package/dist/src/core/bootstrap/providers/provider-order.d.ts +1 -1
- package/dist/src/core/bootstrap/providers/provider-order.d.ts.map +1 -1
- package/dist/src/core/bootstrap/ssr.d.ts +47 -0
- package/dist/src/core/bootstrap/ssr.d.ts.map +1 -0
- package/dist/src/core/bootstrap/ssr.js +125 -0
- package/dist/src/core/bootstrap/ssr.js.map +1 -0
- package/dist/src/core/cache/ViewCache.d.ts +1 -1
- package/dist/src/core/cache/ViewCache.d.ts.map +1 -1
- package/dist/src/core/cache/index.d.ts +1 -1
- package/dist/src/core/cache/index.d.ts.map +1 -1
- package/dist/src/core/cache/index.js +1 -1
- package/dist/src/core/cache/index.js.map +1 -1
- package/dist/src/core/contracts/ApplicationInterface.d.ts +13 -11
- package/dist/src/core/contracts/ApplicationInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/BlockInterface.d.ts +10 -10
- package/dist/src/core/contracts/BlockInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/BundleInterface.d.ts +38 -0
- package/dist/src/core/contracts/BundleInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/BundleInterface.js +2 -0
- package/dist/src/core/contracts/BundleInterface.js.map +1 -0
- package/dist/src/core/contracts/ComponentInterface.d.ts +7 -6
- package/dist/src/core/contracts/ComponentInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ElementInterface.d.ts +91 -27
- package/dist/src/core/contracts/ElementInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HeadServiceInterface.d.ts +37 -0
- package/dist/src/core/contracts/HeadServiceInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/HeadServiceInterface.js +3 -0
- package/dist/src/core/contracts/HeadServiceInterface.js.map +1 -0
- package/dist/src/core/contracts/HelperInterface.d.ts +9 -1
- package/dist/src/core/contracts/HelperInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/HttpServiceInterface.d.ts +3 -0
- package/dist/src/core/contracts/HttpServiceInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/MarkerInterface.d.ts +29 -44
- package/dist/src/core/contracts/MarkerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ReactiveInterface.d.ts +9 -9
- package/dist/src/core/contracts/ReactiveInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/RouterInterface.d.ts +5 -2
- package/dist/src/core/contracts/RouterInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/SectionInterface.d.ts +48 -0
- package/dist/src/core/contracts/SectionInterface.d.ts.map +1 -0
- package/dist/src/core/contracts/SectionInterface.js +2 -0
- package/dist/src/core/contracts/SectionInterface.js.map +1 -0
- package/dist/src/core/contracts/ServiceProviderInterface.d.ts +3 -3
- package/dist/src/core/contracts/ServiceProviderInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewControllerInterface.d.ts +116 -15
- package/dist/src/core/contracts/ViewControllerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewInterface.d.ts +31 -5
- package/dist/src/core/contracts/ViewInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewManagerInterface.d.ts +47 -5
- package/dist/src/core/contracts/ViewManagerInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/ViewStateInterface.d.ts +39 -2
- package/dist/src/core/contracts/ViewStateInterface.d.ts.map +1 -1
- package/dist/src/core/contracts/common.d.ts +3 -3
- package/dist/src/core/contracts/common.js +3 -3
- package/dist/src/core/contracts/utils.d.ts +19 -19
- package/dist/src/core/contracts/utils.d.ts.map +1 -1
- package/dist/src/core/contracts/views.d.ts +1 -22
- package/dist/src/core/contracts/views.d.ts.map +1 -1
- package/dist/src/core/devtools/hook.d.ts +66 -0
- package/dist/src/core/devtools/hook.d.ts.map +1 -0
- package/dist/src/core/devtools/hook.js +114 -0
- package/dist/src/core/devtools/hook.js.map +1 -0
- package/dist/src/core/devtools/index.d.ts +34 -0
- package/dist/src/core/devtools/index.d.ts.map +1 -0
- package/dist/src/core/devtools/index.js +33 -0
- package/dist/src/core/devtools/index.js.map +1 -0
- package/dist/src/core/devtools/inspector.d.ts +31 -0
- package/dist/src/core/devtools/inspector.d.ts.map +1 -0
- package/dist/src/core/devtools/inspector.js +249 -0
- package/dist/src/core/devtools/inspector.js.map +1 -0
- package/dist/src/core/elements/Block.d.ts +9 -7
- package/dist/src/core/elements/Block.d.ts.map +1 -1
- package/dist/src/core/elements/Block.js +33 -9
- package/dist/src/core/elements/Block.js.map +1 -1
- package/dist/src/core/elements/BlockOutlet.d.ts +19 -8
- package/dist/src/core/elements/BlockOutlet.d.ts.map +1 -1
- package/dist/src/core/elements/BlockOutlet.js +57 -13
- package/dist/src/core/elements/BlockOutlet.js.map +1 -1
- package/dist/src/core/elements/Component.d.ts +83 -11
- package/dist/src/core/elements/Component.d.ts.map +1 -1
- package/dist/src/core/elements/Component.js +334 -13
- package/dist/src/core/elements/Component.js.map +1 -1
- package/dist/src/core/elements/ForeachSlotCache.d.ts +95 -0
- package/dist/src/core/elements/ForeachSlotCache.d.ts.map +1 -0
- package/dist/src/core/elements/ForeachSlotCache.js +149 -0
- package/dist/src/core/elements/ForeachSlotCache.js.map +1 -0
- package/dist/src/core/elements/Fragment.d.ts +19 -13
- package/dist/src/core/elements/Fragment.d.ts.map +1 -1
- package/dist/src/core/elements/Fragment.js +43 -20
- package/dist/src/core/elements/Fragment.js.map +1 -1
- package/dist/src/core/elements/Html.d.ts +89 -12
- package/dist/src/core/elements/Html.d.ts.map +1 -1
- package/dist/src/core/elements/Html.js +707 -106
- package/dist/src/core/elements/Html.js.map +1 -1
- package/dist/src/core/elements/Output.d.ts +34 -10
- package/dist/src/core/elements/Output.d.ts.map +1 -1
- package/dist/src/core/elements/Output.js +141 -26
- package/dist/src/core/elements/Output.js.map +1 -1
- package/dist/src/core/elements/Reactive.d.ts +85 -14
- package/dist/src/core/elements/Reactive.d.ts.map +1 -1
- package/dist/src/core/elements/Reactive.js +315 -41
- package/dist/src/core/elements/Reactive.js.map +1 -1
- package/dist/src/core/elements/TextElement.d.ts +11 -7
- package/dist/src/core/elements/TextElement.d.ts.map +1 -1
- package/dist/src/core/elements/TextElement.js +18 -11
- package/dist/src/core/elements/TextElement.js.map +1 -1
- package/dist/src/core/elements/Wrapper.d.ts +24 -13
- package/dist/src/core/elements/Wrapper.d.ts.map +1 -1
- package/dist/src/core/elements/Wrapper.js +65 -8
- package/dist/src/core/elements/Wrapper.js.map +1 -1
- package/dist/src/core/elements/Yield.d.ts +10 -9
- package/dist/src/core/elements/Yield.d.ts.map +1 -1
- package/dist/src/core/elements/Yield.js +31 -19
- package/dist/src/core/elements/Yield.js.map +1 -1
- package/dist/src/core/elements/helpers.d.ts +7 -7
- package/dist/src/core/elements/helpers.d.ts.map +1 -1
- package/dist/src/core/elements/helpers.js +4 -4
- package/dist/src/core/elements/index.d.ts +12 -11
- package/dist/src/core/elements/index.d.ts.map +1 -1
- package/dist/src/core/elements/index.js +12 -11
- package/dist/src/core/elements/index.js.map +1 -1
- package/dist/src/core/elements/reconcileChildren.d.ts +7 -0
- package/dist/src/core/elements/reconcileChildren.d.ts.map +1 -0
- package/dist/src/core/elements/reconcileChildren.js +74 -0
- package/dist/src/core/elements/reconcileChildren.js.map +1 -0
- package/dist/src/core/hellpers/utils.d.ts +2 -2
- package/dist/src/core/hellpers/utils.js +4 -4
- package/dist/src/core/helpers/ApiClient.d.ts +58 -0
- package/dist/src/core/helpers/ApiClient.d.ts.map +1 -0
- package/dist/src/core/helpers/ApiClient.js +131 -0
- package/dist/src/core/helpers/ApiClient.js.map +1 -0
- package/dist/src/core/helpers/app.d.ts +3 -0
- package/dist/src/core/helpers/app.d.ts.map +1 -0
- package/dist/src/core/helpers/app.js +63 -0
- package/dist/src/core/helpers/app.js.map +1 -0
- package/dist/src/core/helpers/transition.d.ts +34 -0
- package/dist/src/core/helpers/transition.d.ts.map +1 -0
- package/dist/src/core/helpers/transition.js +118 -0
- package/dist/src/core/helpers/transition.js.map +1 -0
- package/dist/src/core/helpers/utils.d.ts +26 -0
- package/dist/src/core/helpers/utils.d.ts.map +1 -0
- package/dist/src/core/helpers/utils.js +96 -0
- package/dist/src/core/helpers/utils.js.map +1 -0
- package/dist/src/core/helpers/view.d.ts +39 -0
- package/dist/src/core/helpers/view.d.ts.map +1 -0
- package/dist/src/core/helpers/view.js +286 -0
- package/dist/src/core/helpers/view.js.map +1 -0
- package/dist/src/core/routers/Router.d.ts +108 -7
- package/dist/src/core/routers/Router.d.ts.map +1 -1
- package/dist/src/core/routers/Router.js +444 -68
- package/dist/src/core/routers/Router.js.map +1 -1
- package/dist/src/core/routers/index.d.ts +2 -2
- package/dist/src/core/routers/index.d.ts.map +1 -1
- package/dist/src/core/routers/index.js +1 -1
- package/dist/src/core/routers/index.js.map +1 -1
- package/dist/src/core/services/AssetManager.d.ts +112 -0
- package/dist/src/core/services/AssetManager.d.ts.map +1 -0
- package/dist/src/core/services/AssetManager.js +361 -0
- package/dist/src/core/services/AssetManager.js.map +1 -0
- package/dist/src/core/services/BlockManager.d.ts +73 -2
- package/dist/src/core/services/BlockManager.d.ts.map +1 -1
- package/dist/src/core/services/BlockManager.js +270 -48
- package/dist/src/core/services/BlockManager.js.map +1 -1
- package/dist/src/core/services/DomService.js +3 -3
- package/dist/src/core/services/DomService.js.map +1 -1
- package/dist/src/core/services/HeadService.d.ts +24 -0
- package/dist/src/core/services/HeadService.d.ts.map +1 -0
- package/dist/src/core/services/HeadService.js +120 -0
- package/dist/src/core/services/HeadService.js.map +1 -0
- package/dist/src/core/services/HelperService.d.ts +14 -3
- package/dist/src/core/services/HelperService.d.ts.map +1 -1
- package/dist/src/core/services/HelperService.js +32 -2
- package/dist/src/core/services/HelperService.js.map +1 -1
- package/dist/src/core/services/HttpService.d.ts +8 -0
- package/dist/src/core/services/HttpService.d.ts.map +1 -1
- package/dist/src/core/services/HttpService.js +121 -47
- package/dist/src/core/services/HttpService.js.map +1 -1
- package/dist/src/core/services/MarkerRegistry.d.ts +51 -8
- package/dist/src/core/services/MarkerRegistry.d.ts.map +1 -1
- package/dist/src/core/services/MarkerRegistry.js +135 -14
- package/dist/src/core/services/MarkerRegistry.js.map +1 -1
- package/dist/src/core/services/PageCache.d.ts +101 -0
- package/dist/src/core/services/PageCache.d.ts.map +1 -0
- package/dist/src/core/services/PageCache.js +184 -0
- package/dist/src/core/services/PageCache.js.map +1 -0
- package/dist/src/core/services/SectionManager.d.ts +100 -0
- package/dist/src/core/services/SectionManager.d.ts.map +1 -0
- package/dist/src/core/services/SectionManager.js +408 -0
- package/dist/src/core/services/SectionManager.js.map +1 -0
- package/dist/src/core/services/StorageService.js +1 -1
- package/dist/src/core/services/TranslationService.d.ts +21 -0
- package/dist/src/core/services/TranslationService.d.ts.map +1 -0
- package/dist/src/core/services/TranslationService.js +95 -0
- package/dist/src/core/services/TranslationService.js.map +1 -0
- package/dist/src/core/services/index.d.ts +11 -8
- package/dist/src/core/services/index.d.ts.map +1 -1
- package/dist/src/core/services/index.js +11 -8
- package/dist/src/core/services/index.js.map +1 -1
- package/dist/src/core/types/utils.d.ts +8 -8
- package/dist/src/core/types/utils.d.ts.map +1 -1
- package/dist/src/core/view/LoopContext.d.ts +21 -1
- package/dist/src/core/view/LoopContext.d.ts.map +1 -1
- package/dist/src/core/view/LoopContext.js +36 -0
- package/dist/src/core/view/LoopContext.js.map +1 -1
- package/dist/src/core/view/ResourceScope.d.ts +38 -0
- package/dist/src/core/view/ResourceScope.d.ts.map +1 -0
- package/dist/src/core/view/ResourceScope.js +167 -0
- package/dist/src/core/view/ResourceScope.js.map +1 -0
- package/dist/src/core/view/Section.d.ts +17 -32
- package/dist/src/core/view/Section.d.ts.map +1 -1
- package/dist/src/core/view/Section.js +14 -55
- package/dist/src/core/view/Section.js.map +1 -1
- package/dist/src/core/view/View.d.ts +43 -7
- package/dist/src/core/view/View.d.ts.map +1 -1
- package/dist/src/core/view/View.js +22 -4
- package/dist/src/core/view/View.js.map +1 -1
- package/dist/src/core/view/ViewController.d.ts +313 -40
- package/dist/src/core/view/ViewController.d.ts.map +1 -1
- package/dist/src/core/view/ViewController.js +1002 -147
- package/dist/src/core/view/ViewController.js.map +1 -1
- package/dist/src/core/view/ViewManager.d.ts +318 -27
- package/dist/src/core/view/ViewManager.d.ts.map +1 -1
- package/dist/src/core/view/ViewManager.js +1199 -258
- package/dist/src/core/view/ViewManager.js.map +1 -1
- package/dist/src/core/view/ViewState.d.ts +185 -6
- package/dist/src/core/view/ViewState.d.ts.map +1 -1
- package/dist/src/core/view/ViewState.js +851 -123
- package/dist/src/core/view/ViewState.js.map +1 -1
- package/dist/src/core/view/index.d.ts +5 -5
- package/dist/src/core/view/index.d.ts.map +1 -1
- package/dist/src/core/view/index.js +5 -5
- package/dist/src/core/view/index.js.map +1 -1
- package/dist/src/testing/index.d.ts +69 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +140 -0
- package/dist/src/testing/index.js.map +1 -0
- package/index.ts +29 -5
- package/package.json +30 -21
- package/plugins.ts +2 -2
- package/src/core/app/Application.ts +58 -31
- package/src/core/bootstrap/app.ts +80 -6
- package/src/core/bootstrap/bundle.ts +101 -0
- package/src/core/bootstrap/providers/ApiServiceProvider.ts +4 -4
- package/src/core/bootstrap/providers/CoreServiceProvider.ts +14 -8
- package/src/core/bootstrap/providers/HelperServiceProvider.ts +8 -3
- package/src/core/bootstrap/providers/RouteServiceProvider.ts +15 -4
- package/src/core/bootstrap/providers/ServiceProvider.ts +8 -8
- package/src/core/bootstrap/providers/ViewServiceProvider.ts +3 -3
- package/src/core/bootstrap/providers/default-providers.ts +51 -16
- package/src/core/bootstrap/providers/index.ts +9 -9
- package/src/core/bootstrap/providers/provider-order.test.ts +16 -27
- package/src/core/bootstrap/providers/provider-order.ts +1 -1
- package/src/core/bootstrap/ssr.ts +136 -0
- package/src/core/cache/ViewCache.ts +1 -1
- package/src/core/cache/index.ts +1 -1
- package/src/core/contracts/ApplicationInterface.ts +13 -11
- package/src/core/contracts/BlockInterface.ts +18 -18
- package/src/core/contracts/BundleInterface.ts +40 -0
- package/src/core/contracts/ComponentInterface.ts +7 -6
- package/src/core/contracts/ElementInterface.ts +95 -27
- package/src/core/contracts/HeadServiceInterface.ts +41 -0
- package/src/core/contracts/HelperInterface.ts +9 -1
- package/src/core/contracts/HttpServiceInterface.ts +3 -0
- package/src/core/contracts/MarkerInterface.ts +22 -49
- package/src/core/contracts/ReactiveInterface.ts +9 -9
- package/src/core/contracts/RouterInterface.ts +5 -2
- package/src/core/contracts/SectionInterface.ts +49 -0
- package/src/core/contracts/ServiceProviderInterface.ts +3 -3
- package/src/core/contracts/ViewControllerInterface.ts +124 -16
- package/src/core/contracts/ViewInterface.ts +34 -5
- package/src/core/contracts/ViewManagerInterface.ts +40 -5
- package/src/core/contracts/ViewStateInterface.ts +39 -2
- package/src/core/contracts/common.ts +3 -3
- package/src/core/contracts/utils.ts +19 -19
- package/src/core/contracts/views.ts +2 -22
- package/src/core/devtools/hook.ts +142 -0
- package/src/core/devtools/index.ts +38 -0
- package/src/core/devtools/inspector.ts +270 -0
- package/src/core/elements/Block.ts +43 -21
- package/src/core/elements/BlockOutlet.ts +61 -21
- package/src/core/elements/Component.ts +352 -18
- package/src/core/elements/ElementManager.ts +9 -9
- package/src/core/elements/ForeachSlotCache.ts +170 -0
- package/src/core/elements/Fragment.ts +50 -32
- package/src/core/elements/Html.ts +696 -117
- package/src/core/elements/Output.ts +147 -30
- package/src/core/elements/Reactive.ts +329 -52
- package/src/core/elements/TextElement.ts +25 -16
- package/src/core/elements/Wrapper.ts +84 -18
- package/src/core/elements/Yield.ts +35 -24
- package/src/core/elements/helpers.ts +12 -12
- package/src/core/elements/index.ts +25 -24
- package/src/core/elements/reconcileChildren.ts +70 -0
- package/src/core/{hellpers → helpers}/ApiClient.ts +8 -1
- package/src/core/{hellpers → helpers}/app.ts +13 -2
- package/src/core/helpers/transition.ts +127 -0
- package/src/core/{hellpers → helpers}/utils.ts +4 -4
- package/src/core/helpers/view.ts +283 -0
- package/src/core/routers/Router.ts +458 -64
- package/src/core/routers/index.ts +2 -2
- package/src/core/services/AssetManager.ts +387 -0
- package/src/core/services/BlockManager.ts +272 -50
- package/src/core/services/DomService.ts +3 -3
- package/src/core/services/HeadService.ts +124 -0
- package/src/core/services/HelperService.ts +34 -4
- package/src/core/services/HttpService.ts +116 -55
- package/src/core/services/MarkerRegistry.ts +142 -16
- package/src/core/services/PageCache.ts +239 -0
- package/src/core/services/SectionManager.ts +387 -0
- package/src/core/services/StorageService.ts +1 -1
- package/src/core/services/TranslationService.ts +91 -0
- package/src/core/services/index.ts +11 -8
- package/src/core/types/utils.ts +8 -8
- package/src/core/view/LoopContext.ts +38 -1
- package/src/core/view/ResourceScope.ts +174 -0
- package/src/core/view/Section.ts +21 -68
- package/src/core/view/View.ts +57 -8
- package/src/core/view/ViewController.ts +1070 -186
- package/src/core/view/ViewManager.ts +1346 -272
- package/src/core/view/ViewState.ts +834 -116
- package/src/core/view/index.ts +5 -5
- package/src/testing/index.ts +184 -0
- package/dist/src/core/services/MarkerService.d.ts +0 -299
- package/dist/src/core/services/MarkerService.d.ts.map +0 -1
- package/dist/src/core/services/MarkerService.js +0 -723
- package/dist/src/core/services/MarkerService.js.map +0 -1
- package/src/core/services/MarkerService.ts +0 -923
|
@@ -1,22 +1,29 @@
|
|
|
1
|
-
import { ViewState } from "./ViewState";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
1
|
+
import { ViewState } from "./ViewState.js";
|
|
2
|
+
import { ResourceScope } from './ResourceScope.js';
|
|
3
|
+
import { LoopContext } from "./LoopContext.js";
|
|
4
|
+
import { Reactive } from "../elements/Reactive.js";
|
|
5
|
+
import BlockManager from "../services/BlockManager.js";
|
|
6
|
+
import SectionManager from "../services/SectionManager.js";
|
|
7
|
+
import devtools from "../devtools/hook.js";
|
|
8
|
+
import { Component } from "../elements/Component.js";
|
|
9
|
+
import { generateUUID } from "../helpers/utils.js";
|
|
10
|
+
import { Output } from "../elements/Output.js";
|
|
11
|
+
import { Section } from "./Section.js";
|
|
12
|
+
import { Block, BlockOutlet, Fragment, Html, TextElement } from "../elements/index.js";
|
|
13
|
+
import { Wrapper } from "../elements/Wrapper.js";
|
|
14
|
+
import { YieldElement } from "../elements/Yield.js";
|
|
15
|
+
import { app } from "../helpers/app.js";
|
|
16
|
+
import { ForeachSlotCache } from "../elements/ForeachSlotCache.js";
|
|
17
|
+
import { reuseStaticText } from '../elements/reconcileChildren.js';
|
|
18
|
+
import AssetManager from "../services/AssetManager.js";
|
|
13
19
|
/**
|
|
14
20
|
* ViewController — the brain behind a View.
|
|
15
21
|
*
|
|
16
22
|
* Manages:
|
|
17
23
|
* - Reactive state (ViewState) — useState, subscribe, batch flush
|
|
18
24
|
* - DOM element tree — via Html, Reactive, TextElement, Fragment
|
|
19
|
-
* -
|
|
25
|
+
* - Events — addEventListener TRỰC TIẾP trên từng element (KHÔNG delegation),
|
|
26
|
+
* dọn hàng loạt bằng một AbortController chung. Giống React 17+/Vue/Svelte.
|
|
20
27
|
* - Loop contexts — @foreach, @for, @while with LoopContext stack
|
|
21
28
|
* - Block management — for layout views with @useBlock
|
|
22
29
|
* - Lifecycle — setup, render, destroy
|
|
@@ -32,10 +39,30 @@ import { app } from "../hellpers/app";
|
|
|
32
39
|
* this.__ctrl__.__foreach(items, (item, key, index, loop) => [...])
|
|
33
40
|
*/
|
|
34
41
|
export class ViewController {
|
|
42
|
+
/** Deferred child factories retain the row scope in which they were created. */
|
|
43
|
+
scopeFactory(factory) {
|
|
44
|
+
const registry = this.foreachRegistry;
|
|
45
|
+
if (!registry)
|
|
46
|
+
return factory;
|
|
47
|
+
return ((...args) => this.inForeachScope(registry, () => factory(...args)));
|
|
48
|
+
}
|
|
49
|
+
inForeachScope(registry, run) {
|
|
50
|
+
const previous = this.foreachRegistry;
|
|
51
|
+
const skip = this._foreachSkipRegistry;
|
|
52
|
+
this.foreachRegistry = registry;
|
|
53
|
+
this._foreachSkipRegistry = false;
|
|
54
|
+
try {
|
|
55
|
+
return run();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
this.foreachRegistry = previous;
|
|
59
|
+
this._foreachSkipRegistry = skip;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
35
62
|
// ─── Constructor ────────────────────────────────────────────
|
|
36
63
|
constructor(view, path = '', viewType = 'view', viewId = null) {
|
|
37
64
|
// ─── Identity ───────────────────────────────────────────────
|
|
38
|
-
this.
|
|
65
|
+
this.saoType = 'ViewController';
|
|
39
66
|
// ─── View Hierarchy ─────────────────────────────────────────
|
|
40
67
|
/** Parent view controller (for nested views) */
|
|
41
68
|
this.parent = null;
|
|
@@ -52,6 +79,13 @@ export class ViewController {
|
|
|
52
79
|
// ─── Data & Config ──────────────────────────────────────────
|
|
53
80
|
/** Raw input data from route/parent */
|
|
54
81
|
this.data = {};
|
|
82
|
+
/**
|
|
83
|
+
* Component (@include) đã dựng view này — null với view gốc của route.
|
|
84
|
+
*
|
|
85
|
+
* Đây là lớp trung gian cha↔con: cha khai báo listener tại thẻ, Component
|
|
86
|
+
* giữ bảng đó, con phát qua {@link emit}.
|
|
87
|
+
*/
|
|
88
|
+
this.ownerComponent = null;
|
|
55
89
|
/** User-defined config from setup() */
|
|
56
90
|
this.config = {};
|
|
57
91
|
/** Typed runtime config from compiled $__setup__ */
|
|
@@ -84,17 +118,34 @@ export class ViewController {
|
|
|
84
118
|
this.hasScheduledUpdate = false;
|
|
85
119
|
/** Centralized AbortController for all event listeners */
|
|
86
120
|
this.eventAbortController = new AbortController();
|
|
121
|
+
/** Exact listener references for cleanup when an individual Html node dies. */
|
|
122
|
+
this.elementEventHandlers = new Map();
|
|
87
123
|
// ─── Loop ───────────────────────────────────────────────────
|
|
88
124
|
/** Current loop context stack (@foreach, @for, @while) */
|
|
89
125
|
this.loopContext = null;
|
|
126
|
+
/**
|
|
127
|
+
* Active ForeachSlotCache — được set bởi Reactive.renderForeach() trước khi
|
|
128
|
+
* gọi childrenFactory. __foreach() đọc cache này để quyết định reuse hay create.
|
|
129
|
+
* Reset về null ngay sau khi childrenFactory trả về.
|
|
130
|
+
*/
|
|
131
|
+
this._currentForeachCache = null;
|
|
132
|
+
this._foreachSkipRegistry = false;
|
|
133
|
+
this.foreachRegistry = null;
|
|
134
|
+
this.elementRegistries = new WeakMap();
|
|
135
|
+
this.inlineForeachCaches = new WeakMap();
|
|
90
136
|
// ─── Layout (Sections & Blocks) ─────────────────────────────
|
|
91
137
|
/** Section management across views */
|
|
92
138
|
this.sections = new Map();
|
|
93
139
|
/** Block slots in layout views */
|
|
94
140
|
this.blocks = new Map();
|
|
95
141
|
this.elements = new Map();
|
|
142
|
+
/** element → key của nó trong `elements` (xem registerElement/releaseElement) */
|
|
143
|
+
this.elementKeys = new WeakMap();
|
|
96
144
|
this.preloadElement = null; // For pre-rendering elements before the main render
|
|
97
145
|
this.mainElement = null; // The main rendered element tree
|
|
146
|
+
/** Wrapper instance RIÊNG cho render/prerender — dùng chung sẽ làm
|
|
147
|
+
* preloadElement === mainElement → swap skeleton→main destroy nhầm chính nó */
|
|
148
|
+
this.wrapperInstances = { render: null, prerender: null };
|
|
98
149
|
// ─── Lifecycle Flags ────────────────────────────────────────
|
|
99
150
|
/** Whether this view is currently active (mounted in DOM) */
|
|
100
151
|
this.isActive = false;
|
|
@@ -102,13 +153,34 @@ export class ViewController {
|
|
|
102
153
|
this._isDataCommitted = false;
|
|
103
154
|
/** Whether the view has been mounted */
|
|
104
155
|
this._isMounted = false;
|
|
156
|
+
/** Whether the reactive element tree has been started. */
|
|
157
|
+
this._isStarted = false;
|
|
105
158
|
/** Whether the view has been fully destroyed */
|
|
106
159
|
this._isDestroyed = false;
|
|
160
|
+
/** Whether this instance's styles/scripts are currently counted in AssetManager
|
|
161
|
+
* (true = đang góp 1 ref vào real DOM). Giữ acquire/release cân bằng. */
|
|
162
|
+
this._assetsLive = false;
|
|
107
163
|
/** For future use: scanning DOM for bindings */
|
|
108
164
|
this.isScanMode = false;
|
|
109
165
|
// --- Route params for reference in blocks and sections (set by ViewManager on navigate) ---
|
|
110
166
|
this.urlPath = null;
|
|
111
167
|
this.callingMethod = null; // For debugging: track which method is currently executing
|
|
168
|
+
this.afterDomCallbacks = new Set();
|
|
169
|
+
this.afterDomRAF = null;
|
|
170
|
+
this.afterDomRevision = 0;
|
|
171
|
+
/** Đang chạy onError của chính controller này — chặn đệ quy nếu handler tự throw. */
|
|
172
|
+
this._handlingError = false;
|
|
173
|
+
// ─── Pause / Resume (PageCache lifecycle) ───────────────────
|
|
174
|
+
// State machine: created → active ⇄ paused → destroyed
|
|
175
|
+
// Thiết kế: ROUTE_RENDER_FLOW.md §7. pause/resume là API công khai;
|
|
176
|
+
// stop()/start() là chi tiết nội bộ + đường mount ban đầu.
|
|
177
|
+
this._lifecycleState = 'created';
|
|
178
|
+
/** Data nhận được trong lúc paused (async fetch về muộn) — apply khi resume */
|
|
179
|
+
this._bufferedData = null;
|
|
180
|
+
/** Only children paused by this controller may be resumed by it. */
|
|
181
|
+
this._pausedChildren = [];
|
|
182
|
+
/** Deterministic fallback counter for the (contract-violating) missing-id path. */
|
|
183
|
+
this._missingIncludeIdCounter = 0;
|
|
112
184
|
this.__App = app();
|
|
113
185
|
this.view = view;
|
|
114
186
|
this.path = path;
|
|
@@ -116,6 +188,69 @@ export class ViewController {
|
|
|
116
188
|
this.viewId = viewId ?? this.generateViewId();
|
|
117
189
|
this.states = new ViewState(this);
|
|
118
190
|
}
|
|
191
|
+
get scope() {
|
|
192
|
+
if (!this._scope) {
|
|
193
|
+
this._scope = new ResourceScope(this.states.__, callback => this.afterDom(callback), error => {
|
|
194
|
+
if (!this.handleError(error, { phase: 'update', path: this.path }).handled) {
|
|
195
|
+
console.error(`[ViewController] Watch error in "${this.path}":`, error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (this._isDestroyed)
|
|
199
|
+
this._scope.destroy();
|
|
200
|
+
else if (this._isStarted && this._lifecycleState === 'active')
|
|
201
|
+
this._scope.resume();
|
|
202
|
+
}
|
|
203
|
+
return this._scope;
|
|
204
|
+
}
|
|
205
|
+
/** One-shot callback after this view's state and DOM queues settle. */
|
|
206
|
+
afterDom(callback) {
|
|
207
|
+
if (this._isDestroyed || this._lifecycleState === 'paused')
|
|
208
|
+
return () => { };
|
|
209
|
+
this.afterDomCallbacks.add(callback);
|
|
210
|
+
if (this.afterDomRAF === null) {
|
|
211
|
+
this.afterDomRAF = requestAnimationFrame(() => {
|
|
212
|
+
this.afterDomRAF = null;
|
|
213
|
+
const revision = this.afterDomRevision;
|
|
214
|
+
this.states.__.flushNow();
|
|
215
|
+
if (revision === this.afterDomRevision)
|
|
216
|
+
this.flushReactiveUpdatesNow();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return () => {
|
|
220
|
+
this.afterDomCallbacks.delete(callback);
|
|
221
|
+
if (this.afterDomCallbacks.size === 0 && this.afterDomRAF !== null) {
|
|
222
|
+
cancelAnimationFrame(this.afterDomRAF);
|
|
223
|
+
this.afterDomRAF = null;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
flushAfterDom() {
|
|
228
|
+
if (this._isDestroyed || this._lifecycleState === 'paused')
|
|
229
|
+
return;
|
|
230
|
+
this.afterDomRevision++;
|
|
231
|
+
if (this.afterDomRAF !== null)
|
|
232
|
+
cancelAnimationFrame(this.afterDomRAF);
|
|
233
|
+
this.afterDomRAF = null;
|
|
234
|
+
// Callbacks queued by another callback belong to the next frame.
|
|
235
|
+
const callbacks = Array.from(this.afterDomCallbacks);
|
|
236
|
+
for (const callback of callbacks) {
|
|
237
|
+
if (!this.afterDomCallbacks.delete(callback))
|
|
238
|
+
continue;
|
|
239
|
+
try {
|
|
240
|
+
callback();
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (!this.handleError(error, { phase: 'update', path: this.path }).handled)
|
|
244
|
+
console.error(error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
cancelAfterDom() {
|
|
249
|
+
if (this.afterDomRAF !== null)
|
|
250
|
+
cancelAnimationFrame(this.afterDomRAF);
|
|
251
|
+
this.afterDomRAF = null;
|
|
252
|
+
this.afterDomCallbacks.clear();
|
|
253
|
+
}
|
|
119
254
|
// ─── Lifecycle ──────────────────────────────────────────────
|
|
120
255
|
/**
|
|
121
256
|
* Setup — called by compiled $__setup__ with full config.
|
|
@@ -142,7 +277,7 @@ export class ViewController {
|
|
|
142
277
|
}
|
|
143
278
|
getConfig(key, defaultValue) {
|
|
144
279
|
if (key) {
|
|
145
|
-
return this.runtimeConfig
|
|
280
|
+
return this.runtimeConfig[key] ?? (this.config[key] ?? defaultValue);
|
|
146
281
|
}
|
|
147
282
|
return { ...this.runtimeConfig, ...this.config };
|
|
148
283
|
}
|
|
@@ -150,6 +285,43 @@ export class ViewController {
|
|
|
150
285
|
setStaticConfig(config) {
|
|
151
286
|
this.config = config || {};
|
|
152
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Error boundary: tìm handler gần nhất theo chuỗi `parent`, gọi nó, trả fallback.
|
|
290
|
+
*
|
|
291
|
+
* Bất biến:
|
|
292
|
+
* - handler trả undefined → coi như KHÔNG xử lý, bubble tiếp lên cha
|
|
293
|
+
* (cho phép chỉ log mà không đổi UI).
|
|
294
|
+
* - handler tự throw → bỏ qua boundary đó, bubble tiếp (không lặp vô hạn).
|
|
295
|
+
* - không boundary nào xử lý → { handled:false }, caller giữ hành vi cũ.
|
|
296
|
+
*/
|
|
297
|
+
handleError(err, info) {
|
|
298
|
+
devtools.emit('error', {
|
|
299
|
+
viewId: this.viewId,
|
|
300
|
+
path: info.path,
|
|
301
|
+
detail: { phase: info.phase, message: err instanceof Error ? err.message : String(err) },
|
|
302
|
+
});
|
|
303
|
+
let ctrl = this;
|
|
304
|
+
while (ctrl) {
|
|
305
|
+
const handler = ctrl.getConfig('onError');
|
|
306
|
+
const self = ctrl;
|
|
307
|
+
if (typeof handler === 'function' && !self._handlingError) {
|
|
308
|
+
self._handlingError = true;
|
|
309
|
+
try {
|
|
310
|
+
const fallback = handler.call(ctrl.view, err, info);
|
|
311
|
+
if (fallback !== undefined)
|
|
312
|
+
return { handled: true, fallback };
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
console.error(`[ViewController] onError of "${ctrl.path}" threw:`, e);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
self._handlingError = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
ctrl = ctrl.parent;
|
|
322
|
+
}
|
|
323
|
+
return { handled: false };
|
|
324
|
+
}
|
|
153
325
|
/** Set user-defined properties/methods on the View instance */
|
|
154
326
|
setUserDefinedConfig(userConfig) {
|
|
155
327
|
for (const [key, value] of Object.entries(userConfig)) {
|
|
@@ -186,6 +358,11 @@ export class ViewController {
|
|
|
186
358
|
return output;
|
|
187
359
|
}
|
|
188
360
|
this.renderOutput = output;
|
|
361
|
+
// FIX(baseline#9): gán _rootTree để start()/stop()/destroy() theo controller hoạt động.
|
|
362
|
+
// Chỉ gán khi output là element tree (Wrapper) — không gán khi return superView (View).
|
|
363
|
+
if (output && typeof output.start === 'function' && output.saoType !== 'View') {
|
|
364
|
+
this._rootTree = output;
|
|
365
|
+
}
|
|
189
366
|
this.callingMethod = oldCallingMethod;
|
|
190
367
|
return output;
|
|
191
368
|
}
|
|
@@ -197,6 +374,11 @@ export class ViewController {
|
|
|
197
374
|
return output;
|
|
198
375
|
}
|
|
199
376
|
this.prerenderOutput = output;
|
|
377
|
+
// Skeleton là tree đang live cho tới khi async render thay thế nó.
|
|
378
|
+
// Controller phải start/pause/destroy được tree này như render tree thường.
|
|
379
|
+
if (output && typeof output.start === 'function' && output.saoType !== 'View') {
|
|
380
|
+
this._rootTree = output;
|
|
381
|
+
}
|
|
200
382
|
this.callingMethod = oldCallingMethod;
|
|
201
383
|
return output;
|
|
202
384
|
}
|
|
@@ -215,9 +397,111 @@ export class ViewController {
|
|
|
215
397
|
this.initMode = 'create';
|
|
216
398
|
return output;
|
|
217
399
|
}
|
|
218
|
-
|
|
400
|
+
// ─── Lifecycle Hooks Dispatcher ─────────────────────────────
|
|
401
|
+
// Bộ hook vòng đời (mẫu examples/sao/app.sao) — mỗi transition một cặp
|
|
402
|
+
// before/after. Hook là method tuỳ chọn trên View (this.view[name]):
|
|
403
|
+
// mount → mounting / mounted (DOM gắn vào real DOM)
|
|
404
|
+
// start → starting / started (subscribe reactive + attach events)
|
|
405
|
+
// pause → pausing / paused (vào PageCache — rời real DOM)
|
|
406
|
+
// resume → resuming / resumed (khôi phục từ PageCache)
|
|
407
|
+
// stop → stopping / stopped (huỷ subscribe — deactivate)
|
|
408
|
+
// unmount → unmounting / unmounted (gỡ DOM khỏi real DOM)
|
|
409
|
+
// destroy → destroying / destroyed (cleanup toàn bộ)
|
|
410
|
+
// Legacy alias (tương thích test/app cũ): onMounted≈started, onPause≈paused,
|
|
411
|
+
// onResume≈resumed, onDeactivated≈stopped, onDestroy≈destroyed.
|
|
412
|
+
/** Gọi an toàn một lifecycle hook nếu user có định nghĩa trên View. */
|
|
413
|
+
callHook(name) {
|
|
414
|
+
const fn = this.view?.[name];
|
|
415
|
+
if (typeof fn === 'function') {
|
|
416
|
+
try {
|
|
417
|
+
const result = fn.call(this.view);
|
|
418
|
+
// Lifecycle transitions are deliberately synchronous. If a user hook
|
|
419
|
+
// is async, do not block navigation, but never leave a rejected promise
|
|
420
|
+
// unhandled either.
|
|
421
|
+
if (result && typeof result.then === 'function') {
|
|
422
|
+
Promise.resolve(result).catch((e) => {
|
|
423
|
+
console.error(`[ViewController] async hook "${name}" error in "${this.path}":`, e);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
console.error(`[ViewController] hook "${name}" error in "${this.path}":`, e);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
219
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Mount — instance vào real DOM.
|
|
434
|
+
* - root != null: gắn DOM tree (mainElement/preloadElement) vào root.
|
|
435
|
+
* - root == null: nội dung đã được đặt sẵn (block outlets / component markers),
|
|
436
|
+
* chỉ fire hook + acquire asset.
|
|
437
|
+
* Fire mounting/mounted MỘT lần mỗi vòng đời; pause→resume dùng resuming/resumed.
|
|
438
|
+
*/
|
|
439
|
+
mount(root = null) {
|
|
440
|
+
if (this._isDestroyed)
|
|
441
|
+
return;
|
|
442
|
+
const firstMount = !this._isMounted;
|
|
443
|
+
if (firstMount)
|
|
444
|
+
this.callHook('mounting');
|
|
445
|
+
if (root) {
|
|
446
|
+
this.setParentElement(root);
|
|
447
|
+
const el = this.mainElement ?? this.preloadElement;
|
|
448
|
+
if (el) {
|
|
449
|
+
el.setParentElement(root);
|
|
450
|
+
el.mountTo(root);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
this._isMounted = true;
|
|
454
|
+
this.acquireAssets();
|
|
455
|
+
if (firstMount) {
|
|
456
|
+
this.callHook('mounted');
|
|
457
|
+
devtools.emit('view:mounted', { viewId: this.viewId, path: this.path });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Unmount — gỡ instance khỏi real DOM (fire hook + release asset). */
|
|
220
461
|
unmount() {
|
|
462
|
+
if (this._isDestroyed || !this._isMounted)
|
|
463
|
+
return;
|
|
464
|
+
this.callHook('unmounting');
|
|
465
|
+
this.releaseAssets();
|
|
466
|
+
this._isMounted = false;
|
|
467
|
+
this.callHook('unmounted');
|
|
468
|
+
}
|
|
469
|
+
// ─── Style/Script asset (AssetManager, ref-count theo path) ──
|
|
470
|
+
/** Đăng ký style/script của component vào real DOM (insert khi ref 0→1). */
|
|
471
|
+
acquireAssets() {
|
|
472
|
+
if (this._assetsLive)
|
|
473
|
+
return;
|
|
474
|
+
const styles = this.getConfig('styles');
|
|
475
|
+
const scripts = this.getConfig('scripts');
|
|
476
|
+
if ((!styles || !styles.length) && (!scripts || !scripts.length))
|
|
477
|
+
return;
|
|
478
|
+
this._assetsLive = true;
|
|
479
|
+
AssetManager.acquire(this.path, styles, scripts, this.getSubtreeRoots());
|
|
480
|
+
}
|
|
481
|
+
/** Gỡ đăng ký style/script (remove khi ref 1→0). */
|
|
482
|
+
releaseAssets() {
|
|
483
|
+
if (!this._assetsLive)
|
|
484
|
+
return;
|
|
485
|
+
this._assetsLive = false;
|
|
486
|
+
const styles = this.getConfig('styles');
|
|
487
|
+
const scripts = this.getConfig('scripts');
|
|
488
|
+
AssetManager.release(this.path, styles, scripts);
|
|
489
|
+
}
|
|
490
|
+
/** Các Element gốc của instance (giữa markers của Wrapper) — để tag scoped style. */
|
|
491
|
+
getSubtreeRoots() {
|
|
492
|
+
const wrapper = this.mainElement ?? this.preloadElement;
|
|
493
|
+
const open = wrapper?.openTag ?? null;
|
|
494
|
+
const close = wrapper?.closeTag ?? null;
|
|
495
|
+
if (!open || !close || !open.parentNode)
|
|
496
|
+
return [];
|
|
497
|
+
const roots = [];
|
|
498
|
+
let cur = open.nextSibling;
|
|
499
|
+
while (cur && cur !== close) {
|
|
500
|
+
if (cur.nodeType === 1)
|
|
501
|
+
roots.push(cur);
|
|
502
|
+
cur = cur.nextSibling;
|
|
503
|
+
}
|
|
504
|
+
return roots;
|
|
221
505
|
}
|
|
222
506
|
/**
|
|
223
507
|
* Start — activate reactive subscriptions throughout the element tree.
|
|
@@ -227,99 +511,334 @@ export class ViewController {
|
|
|
227
511
|
* This ensures initial state values are set before subscriptions fire.
|
|
228
512
|
*/
|
|
229
513
|
start() {
|
|
230
|
-
if (this._isDestroyed ||
|
|
514
|
+
if (this._isDestroyed || this._isStarted)
|
|
515
|
+
return;
|
|
516
|
+
// Page extends layout không có _rootTree (render trả về superView) —
|
|
517
|
+
// tree thật của nó là block content, được BlockManager.startAll() kích hoạt.
|
|
518
|
+
if (!this._rootTree && this.blocks.size === 0)
|
|
231
519
|
return;
|
|
520
|
+
this.callHook('starting');
|
|
521
|
+
this._isStarted = true;
|
|
232
522
|
// Recursively start all children (Output, TextElement, Html, Reactive, Fragment)
|
|
233
|
-
if ('start' in this._rootTree && typeof this._rootTree.start === 'function') {
|
|
523
|
+
if (this._rootTree && 'start' in this._rootTree && typeof this._rootTree.start === 'function') {
|
|
234
524
|
this._rootTree.start();
|
|
235
525
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
526
|
+
this._lifecycleState = 'active';
|
|
527
|
+
this.isActive = true;
|
|
528
|
+
this._scope?.resume();
|
|
529
|
+
this.callHook('started');
|
|
530
|
+
this.callHook('onMounted'); // legacy alias
|
|
531
|
+
}
|
|
532
|
+
get lifecycleState() {
|
|
533
|
+
return this._lifecycleState;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Pause — tạm dừng để vào PageCache:
|
|
537
|
+
* 1. Flush nốt RAF pending (không mất update đang chờ)
|
|
538
|
+
* 2. State listeners → dirty-mode (KHÔNG unsubscribe — ghi sổ key đổi)
|
|
539
|
+
* 3. Hook onPause cho user dọn tài nguyên thô
|
|
540
|
+
* DOM event listeners giữ nguyên — DOM sẽ bị detach nên vô hại.
|
|
541
|
+
*/
|
|
542
|
+
pause() {
|
|
543
|
+
if (this._lifecycleState !== 'active') {
|
|
544
|
+
if (this._lifecycleState === 'paused')
|
|
545
|
+
return;
|
|
546
|
+
console.warn(`[ViewController] pause() bị bỏ qua: "${this.path}" đang ở trạng thái "${this._lifecycleState}".`);
|
|
547
|
+
return;
|
|
244
548
|
}
|
|
549
|
+
this.callHook('pausing');
|
|
550
|
+
this._scope?.pause();
|
|
551
|
+
this.cancelAfterDom();
|
|
552
|
+
// 1. Flush nốt mọi update đang chờ → DOM là snapshot nhất quán
|
|
553
|
+
this.states.__.flushNow();
|
|
554
|
+
this.flushReactiveUpdatesNow();
|
|
555
|
+
// Parent pauses before descendants; descendants finish before parent.
|
|
556
|
+
// Snapshot prevents resuming children that were already inactive on entry.
|
|
557
|
+
this._pausedChildren = this.children.filter((child) => child.lifecycleState === 'active');
|
|
558
|
+
for (let i = this._pausedChildren.length - 1; i >= 0; i--) {
|
|
559
|
+
this._pausedChildren[i].pause();
|
|
560
|
+
}
|
|
561
|
+
// 2. Dirty-mode
|
|
562
|
+
this.states.__.pause();
|
|
563
|
+
this._lifecycleState = 'paused';
|
|
564
|
+
this.isActive = false;
|
|
565
|
+
// 3. Rời real DOM (sắp detach vào PageCache) → gỡ style/script (ref--)
|
|
566
|
+
this.releaseAssets();
|
|
567
|
+
// 4. Hook
|
|
568
|
+
this.callHook('paused');
|
|
569
|
+
this.callHook('onPause'); // legacy alias
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Resume — khôi phục từ PageCache:
|
|
573
|
+
* 1. Apply buffered data (async về trong lúc paused)
|
|
574
|
+
* 2. Thoát dirty-mode → flush đúng các vùng phụ thuộc dirty keys
|
|
575
|
+
* 3. Hook onResume — nơi user quyết định refetch (theo TTL riêng...)
|
|
576
|
+
*/
|
|
577
|
+
resume() {
|
|
578
|
+
if (this._lifecycleState !== 'paused') {
|
|
579
|
+
console.warn(`[ViewController] resume() bị bỏ qua: "${this.path}" đang ở trạng thái "${this._lifecycleState}".`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.callHook('resuming');
|
|
583
|
+
this._lifecycleState = 'active';
|
|
584
|
+
this.isActive = true;
|
|
585
|
+
// 1. Quay lại real DOM → insert lại style/script (ref++)
|
|
586
|
+
this.acquireAssets();
|
|
587
|
+
// 2. Buffered async data → updateData thật (key đổi sẽ vào pending → flush ngay sau)
|
|
588
|
+
const buffered = this._bufferedData;
|
|
589
|
+
this._bufferedData = null;
|
|
590
|
+
// 3. Thoát dirty-mode; resume() của StateManager tự notify các key dirty
|
|
591
|
+
this.states.__.resume();
|
|
592
|
+
if (buffered) {
|
|
593
|
+
this.updateData(buffered);
|
|
594
|
+
}
|
|
595
|
+
// Resume outside-in: parent state is live before child subscriptions flush.
|
|
596
|
+
const pausedChildren = this._pausedChildren;
|
|
597
|
+
this._pausedChildren = [];
|
|
598
|
+
for (const child of pausedChildren) {
|
|
599
|
+
if (child.lifecycleState === 'paused')
|
|
600
|
+
child.resume();
|
|
601
|
+
}
|
|
602
|
+
this._scope?.resume();
|
|
603
|
+
// 4. Hook
|
|
604
|
+
this.callHook('resumed');
|
|
605
|
+
this.callHook('onResume'); // legacy alias
|
|
606
|
+
}
|
|
607
|
+
/** Flush đồng bộ các reactive update đang chờ RAF */
|
|
608
|
+
flushReactiveUpdatesNow() {
|
|
609
|
+
if (this.hasScheduledUpdate || this.pendingReactiveUpdates.size > 0) {
|
|
610
|
+
this.flushReactiveUpdates();
|
|
611
|
+
this.hasScheduledUpdate = false;
|
|
612
|
+
}
|
|
613
|
+
else
|
|
614
|
+
this.flushAfterDom();
|
|
245
615
|
}
|
|
246
616
|
/**
|
|
247
617
|
* Stop — deactivate reactive subscriptions (for caching/deactivation).
|
|
248
618
|
* DOM stays intact but reactive updates are paused.
|
|
249
619
|
*/
|
|
250
620
|
stop() {
|
|
251
|
-
if (
|
|
621
|
+
if (!this._isStarted)
|
|
252
622
|
return;
|
|
623
|
+
this.callHook('stopping');
|
|
624
|
+
this._isStarted = false;
|
|
625
|
+
this._scope?.pause();
|
|
626
|
+
this.cancelAfterDom();
|
|
253
627
|
// Recursively stop all children
|
|
254
|
-
if ('stop' in this._rootTree && typeof this._rootTree.stop === 'function') {
|
|
628
|
+
if (this._rootTree && 'stop' in this._rootTree && typeof this._rootTree.stop === 'function') {
|
|
255
629
|
this._rootTree.stop();
|
|
256
630
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.view.onDeactivated();
|
|
261
|
-
}
|
|
262
|
-
catch (e) {
|
|
263
|
-
console.error(`[ViewController] onDeactivated error in "${this.path}":`, e);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
631
|
+
this.isActive = false;
|
|
632
|
+
this.callHook('stopped');
|
|
633
|
+
this.callHook('onDeactivated'); // legacy alias
|
|
266
634
|
}
|
|
267
635
|
/** Full destroy — cleanup everything */
|
|
268
636
|
destroy() {
|
|
269
637
|
if (this._isDestroyed)
|
|
270
638
|
return;
|
|
271
|
-
this.
|
|
272
|
-
//
|
|
639
|
+
this.callHook('destroying');
|
|
640
|
+
// stop() must run while the instance is still valid so user cleanup hooks
|
|
641
|
+
// and the element tree are not skipped.
|
|
273
642
|
this.stop();
|
|
643
|
+
// Gỡ style/script nếu instance còn đang giữ ref (chưa qua pause)
|
|
644
|
+
this.releaseAssets();
|
|
645
|
+
this._isDestroyed = true;
|
|
646
|
+
this._lifecycleState = 'destroyed';
|
|
647
|
+
this._scope?.destroy();
|
|
648
|
+
this.cancelAfterDom();
|
|
274
649
|
// Cancel pending updates
|
|
275
650
|
this.pendingReactiveUpdates.clear();
|
|
276
651
|
this.hasScheduledUpdate = false;
|
|
277
652
|
// Abort all event listeners
|
|
278
653
|
this.eventAbortController.abort();
|
|
654
|
+
this.elementEventHandlers.clear();
|
|
655
|
+
// Sắp gỡ DOM khỏi real DOM
|
|
656
|
+
const wasMounted = this._isMounted;
|
|
657
|
+
if (wasMounted)
|
|
658
|
+
this.callHook('unmounting');
|
|
279
659
|
// Destroy root tree
|
|
280
660
|
if (this._rootTree && 'destroy' in this._rootTree && typeof this._rootTree.destroy === 'function') {
|
|
281
661
|
this._rootTree.destroy();
|
|
282
662
|
}
|
|
283
663
|
this._rootTree = null;
|
|
664
|
+
this._isMounted = false;
|
|
665
|
+
// A child controller can exist outside _rootTree (for layout/block paths).
|
|
666
|
+
// Destroy remaining children in reverse ownership order; destroy is idempotent.
|
|
667
|
+
for (let i = this.children.length - 1; i >= 0; i--) {
|
|
668
|
+
this.children[i].destroy();
|
|
669
|
+
}
|
|
670
|
+
this.children = [];
|
|
671
|
+
this._pausedChildren = [];
|
|
672
|
+
if (wasMounted)
|
|
673
|
+
this.callHook('unmounted');
|
|
284
674
|
// Destroy state
|
|
285
675
|
this.states.__.destroy();
|
|
286
676
|
// Cleanup loop context stack
|
|
287
677
|
this.loopContext = null;
|
|
288
|
-
// Fire onDestroy hook
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
678
|
+
// Fire onDestroy hook (legacy alias của 'destroyed')
|
|
679
|
+
this.callHook('onDestroy');
|
|
680
|
+
// Gỡ block content + outlets của view này khỏi BlockManager (tránh leak qua navigation)
|
|
681
|
+
BlockManager.unmountView(this.viewId);
|
|
682
|
+
BlockManager.removeOutletsOfView(this.viewId);
|
|
683
|
+
// Gỡ sections + yields của view này khỏi SectionManager (tránh leak qua navigation)
|
|
684
|
+
SectionManager.unmountView(this.viewId);
|
|
685
|
+
SectionManager.removeYieldsOfView(this.viewId);
|
|
686
|
+
// Clear element registry — tránh giữ reference corpse elements (memory leak)
|
|
687
|
+
this.elements.clear();
|
|
688
|
+
this.sections.clear();
|
|
689
|
+
this.blocks.clear();
|
|
690
|
+
const parent = this.parent;
|
|
691
|
+
this.parent = null;
|
|
692
|
+
parent?.removeChild(this);
|
|
297
693
|
// Nullify references
|
|
298
694
|
this.rootElement = null;
|
|
299
695
|
this.renderFactory = null;
|
|
300
696
|
this.prerenderFactory = null;
|
|
301
697
|
this._isMounted = false;
|
|
698
|
+
this.callHook('destroyed');
|
|
699
|
+
devtools.emit('view:destroyed', { viewId: this.viewId, path: this.path });
|
|
700
|
+
}
|
|
701
|
+
active() {
|
|
702
|
+
this.isActive = true;
|
|
703
|
+
}
|
|
704
|
+
deactive() {
|
|
705
|
+
this.isActive = false;
|
|
302
706
|
}
|
|
303
707
|
// ─── Data Lifecycle ─────────────────────────────────────────
|
|
708
|
+
/**
|
|
709
|
+
* `this` context cho các hàm compiled config (updateVariableData dùng
|
|
710
|
+
* this.config.updateVariableItemData và this.data).
|
|
711
|
+
*/
|
|
712
|
+
makeConfigThis() {
|
|
713
|
+
return {
|
|
714
|
+
config: this.runtimeConfig,
|
|
715
|
+
data: this.data,
|
|
716
|
+
ctrl: this,
|
|
717
|
+
view: this.view,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
304
720
|
/**
|
|
305
721
|
* Commit initial data — set initial state values.
|
|
306
722
|
* Called by ViewManager AFTER render + block mounting.
|
|
307
723
|
*
|
|
308
724
|
* Flow: commitConstructorData() → update$xxx(initial) → lockUpdateRealState()
|
|
725
|
+
* FIX(Phase2): trước đây method này RỖNG — compiled commitConstructorData
|
|
726
|
+
* không bao giờ được gọi.
|
|
309
727
|
*/
|
|
310
728
|
commitData() {
|
|
729
|
+
if (this._isDataCommitted || this._isDestroyed)
|
|
730
|
+
return;
|
|
731
|
+
const fn = this.runtimeConfig.commitConstructorData;
|
|
732
|
+
if (typeof fn === 'function') {
|
|
733
|
+
try {
|
|
734
|
+
fn.call(this.makeConfigThis());
|
|
735
|
+
}
|
|
736
|
+
catch (e) {
|
|
737
|
+
console.error(`[ViewController] commitData error in "${this.path}":`, e);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Lock ENFORCE ở runtime — không phụ thuộc compiler emit lockUpdateRealState()
|
|
741
|
+
// cuối commitConstructorData (view không có state → compiled fn rỗng, không lock).
|
|
742
|
+
// Từ đây update$xxx chỉ chạy được trong cửa sổ unlock của updateData.
|
|
743
|
+
this.states.__.lockUpdateRealState();
|
|
744
|
+
this._isDataCommitted = true;
|
|
311
745
|
}
|
|
312
746
|
/**
|
|
313
|
-
* Update data from external source (navigate same view,
|
|
314
|
-
*
|
|
747
|
+
* Update data from external source (navigate same view, props từ parent...).
|
|
748
|
+
*
|
|
749
|
+
* Contract data vs state (mô hình React — data:props từ ngoài, state:của instance):
|
|
750
|
+
* - TRƯỚC commitData (constructor phase): chỉ merge vào this.data. Factory
|
|
751
|
+
* đã destructure __data__ lúc khởi tạo; state sẽ do commitData() init MỘT
|
|
752
|
+
* lần. KHÔNG chạy updateVariableData ở đây — nếu chạy, sequence
|
|
753
|
+
* unlock→lock của nó làm commitConstructorData về sau thành no-op
|
|
754
|
+
* (update$xxx bị lock chặn) → state init phụ thuộc data rỗng hay không.
|
|
755
|
+
* - SAU commitData: đường props-update chuẩn — unlock → updateVariableData
|
|
756
|
+
* (trait cập nhật biến data + notify các key dẫn xuất từ data) → lock.
|
|
757
|
+
* Instance state (init bằng literal) KHÔNG được reset ở đây — đó là
|
|
758
|
+
* trách nhiệm của compiled updateVariableData (COMPILER_CONTRACT).
|
|
759
|
+
* Khi paused: buffer lại, apply lúc resume (ROUTE_RENDER_FLOW §8.2).
|
|
315
760
|
*/
|
|
316
761
|
updateData(newData) {
|
|
317
|
-
this.
|
|
762
|
+
if (this._isDestroyed)
|
|
763
|
+
return;
|
|
764
|
+
if (this._lifecycleState === 'paused') {
|
|
765
|
+
this._bufferedData = { ...(this._bufferedData ?? {}), ...newData };
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
this.data = { ...this.data, ...newData };
|
|
769
|
+
if (!this._isDataCommitted) {
|
|
770
|
+
// Constructor phase (data mount / async data về TRƯỚC commit):
|
|
771
|
+
// áp data vào biến data qua TRAIT-ONLY (updateVariableItemData từng key)
|
|
772
|
+
// — closure vars nhận giá trị mới để commitData init state từ đó.
|
|
773
|
+
// KHÔNG chạy updateVariableData (tránh re-init state) và KHÔNG
|
|
774
|
+
// đụng lock (commitData cần cửa unlock của constructor phase).
|
|
775
|
+
this.applyDataTrait(newData);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const fn = this.runtimeConfig.updateVariableData;
|
|
779
|
+
if (typeof fn === 'function') {
|
|
780
|
+
this.states.__.unlockUpdateRealState();
|
|
781
|
+
try {
|
|
782
|
+
fn.call(this.makeConfigThis(), newData);
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
console.error(`[ViewController] updateData error in "${this.path}":`, e);
|
|
786
|
+
}
|
|
787
|
+
finally {
|
|
788
|
+
this.states.__.lockUpdateRealState();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
318
791
|
}
|
|
319
792
|
/**
|
|
320
|
-
*
|
|
793
|
+
* Con phát sự kiện lên ĐÚNG cha đã include nó: `emit('edit', card['id'])`.
|
|
794
|
+
*
|
|
795
|
+
* Kênh trực tiếp, không qua App.Event — hai instance cùng view không nghe
|
|
796
|
+
* nhầm của nhau, và không có gì để gỡ đăng ký lúc destroy. Không ai lắng
|
|
797
|
+
* nghe thì im lặng, đúng như một DOM event không listener.
|
|
798
|
+
*
|
|
799
|
+
* Trả về giá trị handler trả về, nên con hỏi cha được (`if (!emit('close'))`).
|
|
800
|
+
*/
|
|
801
|
+
emit(event, ...args) {
|
|
802
|
+
const fn = this.ownerComponent?.listeners?.[event];
|
|
803
|
+
return typeof fn === 'function' ? fn(...args) : undefined;
|
|
804
|
+
}
|
|
805
|
+
/** Áp data vào biến data (trait) từng key — không đụng state, không đụng lock */
|
|
806
|
+
applyDataTrait(newData) {
|
|
807
|
+
const itemFn = this.runtimeConfig.updateVariableItemData;
|
|
808
|
+
if (typeof itemFn !== 'function')
|
|
809
|
+
return;
|
|
810
|
+
for (const key of Object.keys(newData)) {
|
|
811
|
+
try {
|
|
812
|
+
itemFn.call(this.makeConfigThis(), key, newData[key]);
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
console.error(`[ViewController] applyDataTrait error in "${this.path}" (key: ${key}):`, e);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Update single data item — cùng contract với updateData (xem trên).
|
|
321
821
|
*/
|
|
322
822
|
updateDataItem(key, value) {
|
|
823
|
+
this.data[key] = value;
|
|
824
|
+
if (!this._isDataCommitted) {
|
|
825
|
+
// Constructor phase: trait-only, không đụng lock (như updateData)
|
|
826
|
+
this.applyDataTrait({ [key]: value });
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const fn = this.runtimeConfig.updateVariableItemData;
|
|
830
|
+
if (typeof fn === 'function') {
|
|
831
|
+
this.states.__.unlockUpdateRealState();
|
|
832
|
+
try {
|
|
833
|
+
fn.call(this.makeConfigThis(), key, value);
|
|
834
|
+
}
|
|
835
|
+
catch (e) {
|
|
836
|
+
console.error(`[ViewController] updateDataItem error in "${this.path}":`, e);
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
this.states.__.lockUpdateRealState();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
323
842
|
}
|
|
324
843
|
// ─── Event Management ───────────────────────────────────────
|
|
325
844
|
/**
|
|
@@ -331,7 +850,34 @@ export class ViewController {
|
|
|
331
850
|
* - Object with handler + params: { handler: fn, params: [...] }
|
|
332
851
|
* - Object with string handler (method name on view): { handler: 'handleClick' }
|
|
333
852
|
*/
|
|
334
|
-
|
|
853
|
+
/**
|
|
854
|
+
* Bọc handler theo modifier của `@click.prevent.stop(...)`.
|
|
855
|
+
* `self` kiểm TRƯỚC: event từ element con thì coi như không xảy ra, nên
|
|
856
|
+
* cũng không được preventDefault/stopPropagation (giống Vue).
|
|
857
|
+
* `once` KHÔNG xử lý ở đây — nó là option của addEventListener.
|
|
858
|
+
*/
|
|
859
|
+
wrapEventModifiers(fn, modifiers) {
|
|
860
|
+
const checkSelf = modifiers.includes('self');
|
|
861
|
+
const prevent = modifiers.includes('prevent');
|
|
862
|
+
const stop = modifiers.includes('stop');
|
|
863
|
+
if (!checkSelf && !prevent && !stop)
|
|
864
|
+
return fn;
|
|
865
|
+
return function (event) {
|
|
866
|
+
if (checkSelf && event.target !== event.currentTarget)
|
|
867
|
+
return;
|
|
868
|
+
if (prevent)
|
|
869
|
+
event.preventDefault();
|
|
870
|
+
if (stop)
|
|
871
|
+
event.stopPropagation();
|
|
872
|
+
return fn.call(this, event);
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
addEventListener(element, event, handlers, modifiers = []) {
|
|
876
|
+
// Compiled Html supplies the complete handler list for one event. Replacing
|
|
877
|
+
// the previous set makes render/hydrate initialization idempotent.
|
|
878
|
+
this.removeEventListener(element, event);
|
|
879
|
+
const registered = [];
|
|
880
|
+
const once = modifiers.includes('once');
|
|
335
881
|
for (const h of handlers) {
|
|
336
882
|
let fn;
|
|
337
883
|
if (typeof h === 'function') {
|
|
@@ -379,8 +925,29 @@ export class ViewController {
|
|
|
379
925
|
else {
|
|
380
926
|
continue;
|
|
381
927
|
}
|
|
382
|
-
|
|
928
|
+
if (modifiers.length)
|
|
929
|
+
fn = this.wrapEventModifiers(fn, modifiers);
|
|
930
|
+
element.addEventListener(event, fn, { signal: this.eventAbortController.signal, once });
|
|
931
|
+
registered.push(fn);
|
|
383
932
|
}
|
|
933
|
+
if (registered.length > 0) {
|
|
934
|
+
let events = this.elementEventHandlers.get(element);
|
|
935
|
+
if (!events) {
|
|
936
|
+
events = new Map();
|
|
937
|
+
this.elementEventHandlers.set(element, events);
|
|
938
|
+
}
|
|
939
|
+
events.set(event, registered);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
removeEventListener(element, event) {
|
|
943
|
+
const events = this.elementEventHandlers.get(element);
|
|
944
|
+
const listeners = events?.get(event) ?? [];
|
|
945
|
+
for (const listener of listeners) {
|
|
946
|
+
element.removeEventListener(event, listener);
|
|
947
|
+
}
|
|
948
|
+
events?.delete(event);
|
|
949
|
+
if (events?.size === 0)
|
|
950
|
+
this.elementEventHandlers.delete(element);
|
|
384
951
|
}
|
|
385
952
|
// ─── Reactive Scheduling ────────────────────────────────────
|
|
386
953
|
/**
|
|
@@ -396,6 +963,15 @@ export class ViewController {
|
|
|
396
963
|
requestAnimationFrame(() => this.flushReactiveUpdates());
|
|
397
964
|
}
|
|
398
965
|
}
|
|
966
|
+
/**
|
|
967
|
+
* Có vùng reactive nào đang chờ re-render trong frame này không?
|
|
968
|
+
*
|
|
969
|
+
* StateManager dùng để phân biệt "factory ném vì vùng bọc nó sắp bị dựng
|
|
970
|
+
* lại" với lỗi thật (xem StateManager.retryAfterReactiveFlush).
|
|
971
|
+
*/
|
|
972
|
+
hasPendingReactiveUpdate() {
|
|
973
|
+
return this.pendingReactiveUpdates.size > 0;
|
|
974
|
+
}
|
|
399
975
|
flushReactiveUpdates() {
|
|
400
976
|
if (this._isDestroyed)
|
|
401
977
|
return;
|
|
@@ -410,26 +986,53 @@ export class ViewController {
|
|
|
410
986
|
console.error(`[ViewController] Reactive update error in "${this.path}":`, e);
|
|
411
987
|
}
|
|
412
988
|
}
|
|
989
|
+
this.flushAfterDom();
|
|
990
|
+
}
|
|
991
|
+
// Element & Block Creation Methods — called by compiled output to build the element tree directly, instead of returning HTML strings.
|
|
992
|
+
pushBlockAndSections() {
|
|
993
|
+
// 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.
|
|
413
994
|
}
|
|
995
|
+
/**
|
|
996
|
+
* @section(name, ...) — registers content for a matching @yield(name) to
|
|
997
|
+
* consume (possibly in a different controller — page declares, layout
|
|
998
|
+
* yields, same cross-controller relationship as @block/@useBlock).
|
|
999
|
+
*/
|
|
414
1000
|
section(name, config, contentRenderFactory) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1001
|
+
const existing = this.sections.get(name);
|
|
1002
|
+
if (existing) {
|
|
1003
|
+
// Re-render với closure mới (state/props đổi) — cập nhật factory tại chỗ,
|
|
1004
|
+
// SectionManager giữ nguyên đăng ký/subscription.
|
|
1005
|
+
existing.renderFactory = contentRenderFactory;
|
|
1006
|
+
existing.type = config.type;
|
|
1007
|
+
if (config.contentType)
|
|
1008
|
+
existing.contentType = config.contentType;
|
|
1009
|
+
existing.stateKeys = config.stateKeys ?? existing.stateKeys;
|
|
1010
|
+
return existing;
|
|
420
1011
|
}
|
|
421
1012
|
config.ctx = this;
|
|
422
1013
|
config.name = name;
|
|
423
1014
|
config.renderFactory = contentRenderFactory;
|
|
424
1015
|
const section = new Section(config);
|
|
425
1016
|
this.sections.set(name, section);
|
|
1017
|
+
SectionManager.add(section);
|
|
426
1018
|
return section;
|
|
427
1019
|
}
|
|
1020
|
+
/** `this.yieldContent(name, default)` — synchronous resolve, used inside attribute/prop binding factories. */
|
|
1021
|
+
yieldContent(name, defaultValue = null) {
|
|
1022
|
+
return SectionManager.resolve(name, defaultValue);
|
|
1023
|
+
}
|
|
1024
|
+
/** Decode a raw SSR echo as RCDATA, preserving literal angle brackets. */
|
|
1025
|
+
decodeTextContent(value) {
|
|
1026
|
+
const decoder = document.createElement('textarea');
|
|
1027
|
+
decoder.innerHTML = value;
|
|
1028
|
+
return decoder.textContent ?? '';
|
|
1029
|
+
}
|
|
428
1030
|
// template methods called by compiled output — these build the element tree directly
|
|
429
1031
|
block(id, name, contentRenderFactory) {
|
|
430
1032
|
if (!id) {
|
|
431
|
-
id = `
|
|
1033
|
+
id = `block-${name}`;
|
|
432
1034
|
}
|
|
1035
|
+
id = `${this.viewId}-${id}`;
|
|
433
1036
|
const existing = this.blocks.get(id);
|
|
434
1037
|
const initMode = this.initMode;
|
|
435
1038
|
if (existing) {
|
|
@@ -448,15 +1051,19 @@ export class ViewController {
|
|
|
448
1051
|
if (!id) {
|
|
449
1052
|
id = `ob-${name}`;
|
|
450
1053
|
}
|
|
451
|
-
|
|
452
|
-
|
|
1054
|
+
id = `${this.viewId}-${id}`;
|
|
1055
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
1056
|
+
if (existing instanceof BlockOutlet && !existing.__destroyed__) {
|
|
453
1057
|
existing.setParentElement(parentElement);
|
|
454
1058
|
existing.initMode = initMode;
|
|
1059
|
+
// Re-register: BlockManager có thể đã bị reset/clear giữa các lần mount
|
|
1060
|
+
// (addOutlet idempotent) — thiếu dòng này mountAll() sẽ không thấy outlet
|
|
1061
|
+
BlockManager.addOutlet(id, existing);
|
|
455
1062
|
return existing;
|
|
456
1063
|
}
|
|
457
1064
|
const outlet = new BlockOutlet({ ctx: this, name, id, parentElement, initMode });
|
|
458
|
-
this.
|
|
459
|
-
//
|
|
1065
|
+
this.registerElement(id, outlet);
|
|
1066
|
+
BlockManager.addOutlet(id, outlet); // đăng ký để mountAll tìm được outlet theo name
|
|
460
1067
|
return outlet;
|
|
461
1068
|
}
|
|
462
1069
|
mountBlock(id = null, name, parent) {
|
|
@@ -471,7 +1078,7 @@ export class ViewController {
|
|
|
471
1078
|
return this.blockOutlet(id, name, parent);
|
|
472
1079
|
}
|
|
473
1080
|
yield(id, name, defaultValue = null, parentElement = null) {
|
|
474
|
-
const existing = this.elements.get(id);
|
|
1081
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
475
1082
|
if (existing instanceof YieldElement) {
|
|
476
1083
|
existing.setParentElement(parentElement);
|
|
477
1084
|
existing.defaultValue = defaultValue;
|
|
@@ -479,31 +1086,33 @@ export class ViewController {
|
|
|
479
1086
|
}
|
|
480
1087
|
const yieldEl = new YieldElement({ ctx: this, name, initMode: this.initMode, id, defaultValue });
|
|
481
1088
|
yieldEl.setParentElement(parentElement);
|
|
482
|
-
this.
|
|
1089
|
+
this.registerElement(id, yieldEl);
|
|
1090
|
+
// yieldEl.id đã prefix viewId (globally unique) — dùng làm key trong SectionManager,
|
|
1091
|
+
// đăng ký để SectionManager.mountViewSections() tìm được outlet theo name.
|
|
1092
|
+
SectionManager.addYield(yieldEl.id, yieldEl);
|
|
483
1093
|
return yieldEl;
|
|
484
1094
|
}
|
|
485
|
-
yieldContent(name, defaultValue = null) {
|
|
486
|
-
return this.App.View?.yieldContent?.(name, defaultValue) ?? defaultValue;
|
|
487
|
-
}
|
|
488
1095
|
wrapper(factory) {
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
let wrapper = this[
|
|
492
|
-
if (!wrapper) {
|
|
1096
|
+
const which = this.callingMethod === 'prerender' ? 'prerender' : 'render';
|
|
1097
|
+
const key = which === 'prerender' ? 'preloadElement' : 'mainElement';
|
|
1098
|
+
let wrapper = this.wrapperInstances[which];
|
|
1099
|
+
if (!wrapper || wrapper.__destroyed__) {
|
|
493
1100
|
wrapper = new Wrapper({ ctx: this, initMode: this.initMode, parentElement: this.parentElement, childrenFactory: factory });
|
|
494
|
-
this[
|
|
1101
|
+
this.wrapperInstances[which] = wrapper;
|
|
495
1102
|
}
|
|
496
1103
|
else {
|
|
497
1104
|
wrapper.setChildrenFactory(factory);
|
|
498
1105
|
}
|
|
1106
|
+
this[key] = wrapper;
|
|
499
1107
|
return wrapper;
|
|
500
1108
|
}
|
|
501
1109
|
fragment(id = null, parentElement, childrenFactory) {
|
|
1110
|
+
childrenFactory = this.scopeFactory(childrenFactory);
|
|
502
1111
|
if (!id) {
|
|
503
1112
|
id = `fr-${generateUUID(5)}`;
|
|
504
1113
|
}
|
|
505
|
-
const existing = this.
|
|
506
|
-
if (existing
|
|
1114
|
+
const existing = this.aliveFromRegistry(id, Fragment);
|
|
1115
|
+
if (existing) {
|
|
507
1116
|
if (childrenFactory) {
|
|
508
1117
|
existing.setChildrenFactory(childrenFactory);
|
|
509
1118
|
}
|
|
@@ -511,7 +1120,7 @@ export class ViewController {
|
|
|
511
1120
|
}
|
|
512
1121
|
const initMode = this.initMode;
|
|
513
1122
|
const fragment = new Fragment({ ctx: this, id, initMode, parentElement, childrenFactory });
|
|
514
|
-
this.
|
|
1123
|
+
this.registerElement(id, fragment);
|
|
515
1124
|
return fragment;
|
|
516
1125
|
}
|
|
517
1126
|
/**
|
|
@@ -519,11 +1128,13 @@ export class ViewController {
|
|
|
519
1128
|
* These methods are called by the compiled output for loops, conditionals, and other directives.
|
|
520
1129
|
*/
|
|
521
1130
|
html(id = null, tagName, parentElement, config, childrenFactory) {
|
|
1131
|
+
if (childrenFactory)
|
|
1132
|
+
childrenFactory = this.scopeFactory(childrenFactory);
|
|
522
1133
|
if (!id) {
|
|
523
1134
|
id = `el-${tagName}-${generateUUID(5)}`;
|
|
524
1135
|
}
|
|
525
|
-
const existing = this.
|
|
526
|
-
if (existing
|
|
1136
|
+
const existing = this.aliveFromRegistry(id, Html);
|
|
1137
|
+
if (existing) {
|
|
527
1138
|
existing.updateConfig(config);
|
|
528
1139
|
if (childrenFactory) {
|
|
529
1140
|
existing.setChildrenFactory(childrenFactory);
|
|
@@ -531,16 +1142,21 @@ export class ViewController {
|
|
|
531
1142
|
return existing;
|
|
532
1143
|
}
|
|
533
1144
|
const element = new Html({ ctx: this, tagName, id, parentElement, initMode: this.initMode, config, childrenFactory });
|
|
534
|
-
this.
|
|
1145
|
+
this.registerElement(id, element);
|
|
535
1146
|
return element;
|
|
536
1147
|
}
|
|
537
1148
|
reactive(id, type, parentReactive, parentElement, stateKeys, childrenFactory) {
|
|
1149
|
+
childrenFactory = this.scopeFactory(childrenFactory);
|
|
538
1150
|
// check
|
|
539
1151
|
if (!id) {
|
|
540
1152
|
id = `r-${type}-${generateUUID(5)}`;
|
|
541
1153
|
}
|
|
542
|
-
|
|
543
|
-
|
|
1154
|
+
// Prefix viewId để khớp marker id server phát ra (RUNTIME_CONTRACT §5):
|
|
1155
|
+
// compiler emit cùng hash cho JS + Blade, server hoá thành `${viewId}-${hash}`.
|
|
1156
|
+
// Giống output()/block()/blockOutlet() — Reactive trước đây bị thiếu bước này.
|
|
1157
|
+
id = `${this.viewId}-${id}`;
|
|
1158
|
+
const existing = this.aliveFromRegistry(id, Reactive);
|
|
1159
|
+
if (existing) {
|
|
544
1160
|
existing.setChildrenFactory(childrenFactory);
|
|
545
1161
|
existing.setStateKeys(stateKeys);
|
|
546
1162
|
return existing;
|
|
@@ -552,17 +1168,18 @@ export class ViewController {
|
|
|
552
1168
|
parentReactive,
|
|
553
1169
|
parentElement,
|
|
554
1170
|
stateKeys,
|
|
555
|
-
childrenFactory
|
|
1171
|
+
childrenFactory,
|
|
1172
|
+
initMode: this.initMode,
|
|
556
1173
|
});
|
|
557
|
-
this.
|
|
1174
|
+
this.registerElement(id, reactive);
|
|
558
1175
|
return reactive;
|
|
559
1176
|
}
|
|
560
1177
|
output(id, parent, isEscapeHTML = true, stateKeys = [], contentFactory = () => '') {
|
|
561
1178
|
if (!id) {
|
|
562
1179
|
id = `o-${generateUUID(5)}`;
|
|
563
1180
|
}
|
|
564
|
-
const existing = this.
|
|
565
|
-
if (existing
|
|
1181
|
+
const existing = this.aliveFromRegistry(id, Output);
|
|
1182
|
+
if (existing) {
|
|
566
1183
|
existing.setStateKeys(stateKeys);
|
|
567
1184
|
existing.contentFactory = contentFactory;
|
|
568
1185
|
return existing;
|
|
@@ -574,20 +1191,95 @@ export class ViewController {
|
|
|
574
1191
|
stateKeys,
|
|
575
1192
|
isEscapeHTML,
|
|
576
1193
|
contentFactory,
|
|
1194
|
+
initMode: this.initMode,
|
|
577
1195
|
});
|
|
578
|
-
this.
|
|
1196
|
+
this.registerElement(id, output);
|
|
579
1197
|
return output;
|
|
580
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* FIX(baseline#2): trả về TextElement (có saoType) thay vì raw Text node.
|
|
1201
|
+
* Raw Text node bị mountElementList/Reactive.render bỏ qua → text tĩnh biến mất.
|
|
1202
|
+
*/
|
|
581
1203
|
text(text) {
|
|
582
|
-
return
|
|
1204
|
+
return new TextElement({ ctx: this, stateKeys: [], generateText: () => text });
|
|
583
1205
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1206
|
+
/**
|
|
1207
|
+
* Ghi element vào registry + nhớ key để destroy() gỡ lại được.
|
|
1208
|
+
* Key là id THÔ; `element.id` phần lớn đã prefix viewId (`${viewId}-${id}`)
|
|
1209
|
+
* nên element KHÔNG tự suy ra key được — WeakMap giữ ánh xạ, không giữ
|
|
1210
|
+
* element sống.
|
|
1211
|
+
*/
|
|
1212
|
+
registerElement(id, el) {
|
|
1213
|
+
this.elements.set(id, el);
|
|
1214
|
+
this.elementKeys.set(el, id);
|
|
1215
|
+
if (this.foreachRegistry) {
|
|
1216
|
+
this.foreachRegistry.set(id, el);
|
|
1217
|
+
this.elementRegistries.set(el, this.foreachRegistry);
|
|
587
1218
|
}
|
|
588
|
-
|
|
589
|
-
|
|
1219
|
+
return el;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Element tự gỡ khỏi registry trong destroy() — nếu không, `elements` chỉ
|
|
1223
|
+
* co lại lúc destroy view, còn id sinh theo item (`{hash}-${item.id}`) thì
|
|
1224
|
+
* mỗi trang/mỗi lần refresh lại thêm một corpse vĩnh viễn (đo được:
|
|
1225
|
+
* 32 → 182 entry sau 6 trang trong khi DOM vẫn 10 item).
|
|
1226
|
+
*
|
|
1227
|
+
* Guard `=== el` là bắt buộc: pass mới đã ghi element MỚI vào cùng id TRƯỚC
|
|
1228
|
+
* khi prunePass destroy element cũ — xoá vô điều kiện sẽ gỡ nhầm bản mới.
|
|
1229
|
+
*/
|
|
1230
|
+
releaseElement(el) {
|
|
1231
|
+
const id = this.elementKeys.get(el);
|
|
1232
|
+
if (id === undefined)
|
|
1233
|
+
return;
|
|
1234
|
+
this.elementKeys.delete(el);
|
|
1235
|
+
const registry = this.elementRegistries.get(el);
|
|
1236
|
+
if (registry?.get(id) === el)
|
|
1237
|
+
registry.delete(id);
|
|
1238
|
+
this.elementRegistries.delete(el);
|
|
1239
|
+
if (this.elements.get(id) === el)
|
|
1240
|
+
this.elements.delete(id);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Registry guard: element đã destroy không được reuse — trả về corpse
|
|
1244
|
+
* (events đã abort, markers đã gỡ) gây render rỗng / stale closure.
|
|
1245
|
+
* Xem docs/RUNTIME_CONTRACT.md §2.
|
|
1246
|
+
*/
|
|
1247
|
+
aliveFromRegistry(id, ctor) {
|
|
1248
|
+
if (this._foreachSkipRegistry)
|
|
1249
|
+
return null;
|
|
1250
|
+
const existing = this.foreachRegistry ? this.foreachRegistry.get(id) : this.elements.get(id);
|
|
1251
|
+
if (existing instanceof ctor && !existing.__destroyed__) {
|
|
1252
|
+
return existing;
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Resolve the hydrate id for an @include component.
|
|
1258
|
+
*
|
|
1259
|
+
* The compiler ALWAYS emits a deterministic id (md5[:8] of the position-based
|
|
1260
|
+
* base id) so the client marker `s:c:{viewId}-{id}` matches the server-rendered
|
|
1261
|
+
* one. A missing id therefore means a compiler/runtime contract violation — and
|
|
1262
|
+
* in HYDRATE mode it guarantees a marker mismatch (claimSSRMarkers finds nothing
|
|
1263
|
+
* → silent CSR re-render / duplicated DOM).
|
|
1264
|
+
*
|
|
1265
|
+
* Never invent a RANDOM id here: a random id also makes every `elements.get(id)`
|
|
1266
|
+
* lookup miss, so the component is recreated on each render (cache broken). We
|
|
1267
|
+
* surface the violation loudly and fall back to a render-stable deterministic id
|
|
1268
|
+
* so behaviour stays idempotent even in the broken case.
|
|
1269
|
+
*/
|
|
1270
|
+
resolveIncludeId(id, kind, path) {
|
|
1271
|
+
if (id)
|
|
1272
|
+
return id;
|
|
1273
|
+
console.error(`[Saola] ${kind}(): missing hydrate id for view "${path}". The compiler must ` +
|
|
1274
|
+
`pass a deterministic id — marker sync with the server is broken for this component.`);
|
|
1275
|
+
return `cpn-missing-${this._missingIncludeIdCounter++}`;
|
|
1276
|
+
}
|
|
1277
|
+
include(id = null, path = '', parentElement, stateKeys, dataFactory, listeners = {}) {
|
|
1278
|
+
id = this.resolveIncludeId(id, 'include', path);
|
|
1279
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1280
|
+
if (existing) {
|
|
590
1281
|
existing.setDataFactory(dataFactory);
|
|
1282
|
+
existing.setListeners(listeners);
|
|
591
1283
|
if (stateKeys) {
|
|
592
1284
|
existing.setStateKeys(stateKeys);
|
|
593
1285
|
}
|
|
@@ -595,23 +1287,24 @@ export class ViewController {
|
|
|
595
1287
|
}
|
|
596
1288
|
let component = new Component({
|
|
597
1289
|
ctx: this,
|
|
598
|
-
id
|
|
1290
|
+
id,
|
|
599
1291
|
stateKeys,
|
|
600
1292
|
parent: parentElement,
|
|
601
1293
|
dataFactory,
|
|
602
1294
|
path,
|
|
603
1295
|
type: 'default',
|
|
1296
|
+
initMode: this.initMode,
|
|
1297
|
+
listeners,
|
|
604
1298
|
});
|
|
605
|
-
this.
|
|
1299
|
+
this.registerElement(id, component);
|
|
606
1300
|
return component;
|
|
607
1301
|
}
|
|
608
|
-
includeIf(id = null, path, parentElement, stateKeys, dataFactory) {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const existing = this.elements.get(id);
|
|
613
|
-
if (existing instanceof Component) {
|
|
1302
|
+
includeIf(id = null, path, parentElement, stateKeys, dataFactory, listeners = {}) {
|
|
1303
|
+
id = this.resolveIncludeId(id, 'includeIf', path);
|
|
1304
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1305
|
+
if (existing) {
|
|
614
1306
|
existing.setDataFactory(dataFactory);
|
|
1307
|
+
existing.setListeners(listeners);
|
|
615
1308
|
if (stateKeys) {
|
|
616
1309
|
existing.setStateKeys(stateKeys);
|
|
617
1310
|
}
|
|
@@ -619,22 +1312,22 @@ export class ViewController {
|
|
|
619
1312
|
}
|
|
620
1313
|
let component = new Component({
|
|
621
1314
|
ctx: this,
|
|
622
|
-
id
|
|
1315
|
+
id,
|
|
623
1316
|
stateKeys,
|
|
624
1317
|
parent: parentElement,
|
|
625
1318
|
dataFactory,
|
|
626
1319
|
path,
|
|
627
1320
|
type: 'if',
|
|
1321
|
+
initMode: this.initMode,
|
|
1322
|
+
listeners,
|
|
628
1323
|
});
|
|
629
|
-
this.
|
|
1324
|
+
this.registerElement(id, component);
|
|
630
1325
|
return component;
|
|
631
1326
|
}
|
|
632
|
-
includeWhen(id, condition, path, parentElement, stateKeys, dataFactory) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const existing = this.elements.get(id);
|
|
637
|
-
if (existing instanceof Component) {
|
|
1327
|
+
includeWhen(id, condition, path, parentElement, stateKeys, dataFactory, listeners = {}) {
|
|
1328
|
+
id = this.resolveIncludeId(id, 'includeWhen', path);
|
|
1329
|
+
const existing = this.aliveFromRegistry(id, Component);
|
|
1330
|
+
if (existing) {
|
|
638
1331
|
existing.setDataFactory(dataFactory);
|
|
639
1332
|
existing.setCondition(condition);
|
|
640
1333
|
if (stateKeys) {
|
|
@@ -644,20 +1337,23 @@ export class ViewController {
|
|
|
644
1337
|
}
|
|
645
1338
|
let component = new Component({
|
|
646
1339
|
ctx: this,
|
|
647
|
-
id
|
|
1340
|
+
id,
|
|
648
1341
|
stateKeys,
|
|
649
1342
|
parent: parentElement,
|
|
650
1343
|
dataFactory,
|
|
651
1344
|
path,
|
|
652
1345
|
type: 'when',
|
|
653
1346
|
condition,
|
|
1347
|
+
initMode: this.initMode,
|
|
1348
|
+
listeners,
|
|
654
1349
|
});
|
|
655
|
-
this.
|
|
1350
|
+
this.registerElement(id, component);
|
|
656
1351
|
return component;
|
|
657
1352
|
}
|
|
658
1353
|
extendView(path, data = {}) {
|
|
659
1354
|
this.superViewPath = path;
|
|
660
|
-
|
|
1355
|
+
// Sync: render() trả superView ngay (xem ViewManager.resolveViewSync).
|
|
1356
|
+
const superView = this.App.View?.resolveViewSync(path, data, true);
|
|
661
1357
|
if (superView) {
|
|
662
1358
|
this.setSuperView(superView?.__ctrl__);
|
|
663
1359
|
return superView;
|
|
@@ -691,48 +1387,118 @@ export class ViewController {
|
|
|
691
1387
|
* @foreach directive — iterate over array or object.
|
|
692
1388
|
* Returns array of children (not HTML string like the old system).
|
|
693
1389
|
*
|
|
694
|
-
*
|
|
1390
|
+
* # Cache-aware re-render (Phase 5 + 5b)
|
|
1391
|
+
* Khi `_currentForeachCache` được set (bởi Reactive), __foreach claim slot
|
|
1392
|
+
* cho từng item:
|
|
1393
|
+
* - Hit (key khớp + item ref giống) → reuse elements cũ (không recreate DOM)
|
|
1394
|
+
* - Miss → gọi callback → tạo elements mới → store vào cache
|
|
1395
|
+
*
|
|
1396
|
+
* Cache key:
|
|
1397
|
+
* - `keyFn` (compiler emit từ @key(expr)) → field keying (`item.id`)
|
|
1398
|
+
* - không có → object reference của item (identity keying)
|
|
1399
|
+
* Duplicate keys/primitive items phân biệt bằng occurrence (ForeachSlotCache).
|
|
1400
|
+
* Ref đổi nhưng key trùng → recreate (closure đóng gói item cũ) — slot cũ
|
|
1401
|
+
* được Reactive.prunePass destroy.
|
|
1402
|
+
*
|
|
1403
|
+
* @example Compiled output (@key(item.id)):
|
|
695
1404
|
* ctrl.__foreach(items, (item, key, index, loop) => [
|
|
696
|
-
*
|
|
697
|
-
* ])
|
|
1405
|
+
* this.html(`id-${item.id}`, 'div', p, {}, () => [this.text(item.name)])
|
|
1406
|
+
* ], (item) => item.id)
|
|
1407
|
+
*/
|
|
1408
|
+
/**
|
|
1409
|
+
* @children / {{ $children }} — materialize slot content from the parent
|
|
1410
|
+
* include only when render traversal reaches ChildrenNode (compiler emit:
|
|
1411
|
+
* `...this.__children(__ONE_CHILDREN_CONTENT__, parentElement)`).
|
|
1412
|
+
*
|
|
1413
|
+
* content có 2 dạng (xem COMPILER _gen_children_slot):
|
|
1414
|
+
* - function `(parentElement) => elements` — element factory từ
|
|
1415
|
+
* @importInclude/custom tag phía parent. Factory đóng gói `this` của
|
|
1416
|
+
* PARENT ctrl → elements thuộc parent scope (state/registry parent),
|
|
1417
|
+
* giống React children.
|
|
1418
|
+
* - string — SSR data hoặc default '' → render text tĩnh (rỗng → []).
|
|
698
1419
|
*/
|
|
699
|
-
|
|
1420
|
+
__children(content, parentElement) {
|
|
1421
|
+
if (typeof content === 'function') {
|
|
1422
|
+
const out = content(parentElement);
|
|
1423
|
+
if (out === null || out === undefined)
|
|
1424
|
+
return [];
|
|
1425
|
+
return Array.isArray(out) ? out : [out];
|
|
1426
|
+
}
|
|
1427
|
+
if (content === null || content === undefined || content === '')
|
|
1428
|
+
return [];
|
|
1429
|
+
return [this.text(String(content))];
|
|
1430
|
+
}
|
|
1431
|
+
__foreach(list, callback, keyFn, reconcile = false, scopeId) {
|
|
700
1432
|
if (!list || typeof list !== 'object')
|
|
701
1433
|
return [];
|
|
1434
|
+
let cache = Array.isArray(list) || reconcile ? this._currentForeachCache : null;
|
|
1435
|
+
let ownsPass = false;
|
|
1436
|
+
if (!cache && reconcile && scopeId && this.foreachRegistry) {
|
|
1437
|
+
let caches = this.inlineForeachCaches.get(this.foreachRegistry);
|
|
1438
|
+
if (!caches)
|
|
1439
|
+
this.inlineForeachCaches.set(this.foreachRegistry, caches = new Map());
|
|
1440
|
+
cache = caches.get(scopeId) ?? null;
|
|
1441
|
+
if (!cache)
|
|
1442
|
+
caches.set(scopeId, cache = new ForeachSlotCache());
|
|
1443
|
+
cache.beginPass();
|
|
1444
|
+
ownsPass = true;
|
|
1445
|
+
}
|
|
702
1446
|
const result = [];
|
|
1447
|
+
const keys = Array.isArray(list)
|
|
1448
|
+
? Object.keys(list).filter(key => String(Number(key)) === key && Number(key) >= 0 && Number(key) < list.length)
|
|
1449
|
+
: Object.keys(list);
|
|
1450
|
+
const loopCtx = this.__setLoopContext(Array.isArray(list) ? list.length : keys.length);
|
|
1451
|
+
loopCtx.setType('increment');
|
|
703
1452
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1453
|
+
keys.forEach((key, index) => {
|
|
1454
|
+
// Sparse arrays retain their actual index and loop count.
|
|
1455
|
+
const loopIndex = Array.isArray(list) ? Number(key) : index;
|
|
1456
|
+
const item = list[key];
|
|
1457
|
+
loopCtx.setCurrentTimes(loopIndex);
|
|
1458
|
+
const cacheKey = keyFn ? keyFn(item, loopIndex) : item;
|
|
1459
|
+
const claim = cache?.claim(cacheKey, item, reconcile);
|
|
1460
|
+
const slot = claim?.slot;
|
|
1461
|
+
if (slot && !reconcile) {
|
|
1462
|
+
result.push(...slot.elements);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const registry = reconcile ? slot?.registry ?? new Map() : null;
|
|
1466
|
+
const identity = slot?.identity ?? (cache && reconcile ? cache.allocateIdentity(loopIndex) : loopIndex);
|
|
1467
|
+
const previousCache = this._currentForeachCache;
|
|
1468
|
+
const previousSkip = this._foreachSkipRegistry;
|
|
1469
|
+
this._currentForeachCache = null; // nested inline loops own no outer slots
|
|
1470
|
+
if (cache && !reconcile)
|
|
1471
|
+
this._foreachSkipRegistry = true;
|
|
1472
|
+
let output;
|
|
1473
|
+
try {
|
|
1474
|
+
const run = () => callback(item, key, loopIndex, loopCtx.snapshot(), identity);
|
|
1475
|
+
output = registry ? this.inForeachScope(registry, run) : run();
|
|
1476
|
+
}
|
|
1477
|
+
finally {
|
|
1478
|
+
this._currentForeachCache = previousCache;
|
|
1479
|
+
this._foreachSkipRegistry = previousSkip;
|
|
1480
|
+
}
|
|
1481
|
+
let elements = output == null ? [] : Array.isArray(output) ? output : [output];
|
|
1482
|
+
if (slot && reconcile)
|
|
1483
|
+
elements = reuseStaticText(slot.elements, elements);
|
|
1484
|
+
result.push(...elements);
|
|
1485
|
+
if (cache && claim) {
|
|
1486
|
+
if (slot && reconcile) {
|
|
1487
|
+
slot.item = item;
|
|
1488
|
+
slot.identity = identity;
|
|
1489
|
+
slot.elements = elements;
|
|
1490
|
+
for (const el of elements)
|
|
1491
|
+
cache.refreshedElements.add(el);
|
|
730
1492
|
}
|
|
1493
|
+
else
|
|
1494
|
+
cache.store(cacheKey, claim.occ, item, elements, registry ?? undefined).identity = identity;
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
if (ownsPass)
|
|
1498
|
+
cache.prunePass(slot => {
|
|
1499
|
+
for (const el of slot.elements)
|
|
1500
|
+
el.destroy?.();
|
|
731
1501
|
});
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
catch (e) {
|
|
735
|
-
console.error(`[ViewController] @foreach error in "${this.path}":`, e);
|
|
736
1502
|
}
|
|
737
1503
|
finally {
|
|
738
1504
|
this.__resetLoopContext();
|
|
@@ -789,6 +1555,87 @@ export class ViewController {
|
|
|
789
1555
|
}
|
|
790
1556
|
return result;
|
|
791
1557
|
}
|
|
1558
|
+
// ─── Directive Binding Helpers ──────────────────────────────
|
|
1559
|
+
//
|
|
1560
|
+
// Compiler pre-processes @style/@class directives thành template literal
|
|
1561
|
+
// calls trước khi tạo element config.
|
|
1562
|
+
// Ví dụ: @style([...]) → ${this.__styleBinding([...], [...])}
|
|
1563
|
+
// @class([...]) → ${this.__classBinding([...])} (legacy path)
|
|
1564
|
+
//
|
|
1565
|
+
// Các method này được Html._applyAttr() gọi qua factory khi render và khi
|
|
1566
|
+
// state thay đổi (Html đã subscribe stateKeys từ compiled config).
|
|
1567
|
+
//
|
|
1568
|
+
// `__showBinding` từng ở đây, cho `@show`. Compiler đã gỡ `@show`/`@hide`
|
|
1569
|
+
// (chúng hỏng ở CẢ HAI nhánh và ra hai cây DOM khác nhau) nên không còn ai
|
|
1570
|
+
// gọi tới — xem docs/SAO_ELEMENT_DIRECTIVES_RFC.md §10.1.
|
|
1571
|
+
// ────────────────────────────────────────────────────────────
|
|
1572
|
+
/**
|
|
1573
|
+
* __styleBinding — tính inline CSS style string cho @style directive.
|
|
1574
|
+
*
|
|
1575
|
+
* Compiler emit (pre-process trước AST):
|
|
1576
|
+
* @style(['color' => $textColor, 'font-size' => $fontSize])
|
|
1577
|
+
* → ${this.__styleBinding(['textColor', 'fontSize'], [['color', textColor], ['font-size', fontSize]])}
|
|
1578
|
+
*
|
|
1579
|
+
* Hành vi:
|
|
1580
|
+
* - Lọc bỏ entries có value null / undefined / false / '' (không set prop đó)
|
|
1581
|
+
* - Join thành "prop: value; prop: value" string
|
|
1582
|
+
*
|
|
1583
|
+
* @param _stateKeys - Danh sách state keys (đã encode trong config, không dùng ở đây)
|
|
1584
|
+
* @param styles - Mảng [cssProperty, value] pairs
|
|
1585
|
+
*
|
|
1586
|
+
* @example
|
|
1587
|
+
* __styleBinding([], [['color', 'red'], ['font-size', null], ['display', 'block']])
|
|
1588
|
+
* // → "color: red; display: block"
|
|
1589
|
+
*/
|
|
1590
|
+
__styleBinding(_stateKeys, styles) {
|
|
1591
|
+
if (!Array.isArray(styles))
|
|
1592
|
+
return '';
|
|
1593
|
+
return styles
|
|
1594
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== false && value !== '')
|
|
1595
|
+
.map(([prop, value]) => `${prop}: ${String(value)}`)
|
|
1596
|
+
.join('; ');
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* __classBinding — tính CSS class string cho @class binding (legacy template path).
|
|
1600
|
+
*
|
|
1601
|
+
* Chỉ được gọi bởi OLD template_processor (flat compiler) fallback path.
|
|
1602
|
+
* New AST path (RenderGenerator) emit class config trực tiếp vào options.classes[]
|
|
1603
|
+
* → Html.initializeClasses() xử lý, KHÔNG dùng __classBinding.
|
|
1604
|
+
*
|
|
1605
|
+
* Format input (từ class_binding_handler.py):
|
|
1606
|
+
* [{ type: 'static', value: 'foo' },
|
|
1607
|
+
* { type: 'binding', value: 'is-active', states: ['isActive'], checker: () => isActive }]
|
|
1608
|
+
*
|
|
1609
|
+
* @param configs - Mảng class config objects
|
|
1610
|
+
* @returns Space-joined class string
|
|
1611
|
+
*
|
|
1612
|
+
* @example
|
|
1613
|
+
* __classBinding([
|
|
1614
|
+
* { type: 'static', value: 'btn' },
|
|
1615
|
+
* { type: 'binding', value: 'btn-primary', checker: () => isPrimary }
|
|
1616
|
+
* ])
|
|
1617
|
+
* // isPrimary = true → "btn btn-primary"
|
|
1618
|
+
* // isPrimary = false → "btn"
|
|
1619
|
+
*/
|
|
1620
|
+
__classBinding(configs) {
|
|
1621
|
+
if (!Array.isArray(configs))
|
|
1622
|
+
return '';
|
|
1623
|
+
const classes = [];
|
|
1624
|
+
for (const config of configs) {
|
|
1625
|
+
if (!config?.value)
|
|
1626
|
+
continue;
|
|
1627
|
+
if (config.type === 'static') {
|
|
1628
|
+
classes.push(config.value);
|
|
1629
|
+
}
|
|
1630
|
+
else if (config.type === 'binding') {
|
|
1631
|
+
// Thực thi checker để quyết định thêm class không
|
|
1632
|
+
if (typeof config.checker === 'function' && config.checker()) {
|
|
1633
|
+
classes.push(config.value);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return classes.join(' ');
|
|
1638
|
+
}
|
|
792
1639
|
// mounting
|
|
793
1640
|
// ─── Config & App Access ────────────────────────────────────
|
|
794
1641
|
setApp(app) {
|
|
@@ -819,7 +1666,15 @@ export class ViewController {
|
|
|
819
1666
|
this.parent = parent;
|
|
820
1667
|
}
|
|
821
1668
|
addChild(child) {
|
|
822
|
-
this.children.
|
|
1669
|
+
if (!this.children.includes(child))
|
|
1670
|
+
this.children.push(child);
|
|
1671
|
+
}
|
|
1672
|
+
removeChild(child) {
|
|
1673
|
+
const index = this.children.indexOf(child);
|
|
1674
|
+
if (index >= 0)
|
|
1675
|
+
this.children.splice(index, 1);
|
|
1676
|
+
if (child.parent === this)
|
|
1677
|
+
child.setParent(null);
|
|
823
1678
|
}
|
|
824
1679
|
/**
|
|
825
1680
|
* For nested views: set the chain of super views up to the root, so each view has a reference to its layout parents.
|