@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
|
@@ -17,22 +17,39 @@
|
|
|
17
17
|
* - Section system → Block/BlockOutlet
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { ApplicationInterface } from "../contracts/ApplicationInterface";
|
|
21
|
-
import { ActiveRouteInterface, RouterNavigationType } from "../contracts/RouterInterface";
|
|
22
|
-
import { FragmentInterface, HtmlInterface } from "../contracts/utils";
|
|
23
|
-
import type { ViewControllerConfig, ViewControllerInterface } from "../contracts/ViewControllerInterface";
|
|
24
|
-
import type { ViewInterface } from "../contracts/ViewInterface";
|
|
25
|
-
import type { ViewManagerInterface, ActiveViewInfo } from "../contracts/ViewManagerInterface";
|
|
26
|
-
import { BlockManager, BlockManagerService } from "../services/BlockManager";
|
|
27
|
-
import {
|
|
28
|
-
import
|
|
29
|
-
import
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import
|
|
35
|
-
import
|
|
20
|
+
import type { ApplicationInterface } from "../contracts/ApplicationInterface.js";
|
|
21
|
+
import { ActiveRouteInterface, RouterNavigationType } from "../contracts/RouterInterface.js";
|
|
22
|
+
import { FragmentInterface, HtmlInterface } from "../contracts/utils.js";
|
|
23
|
+
import type { ViewControllerConfig, ViewControllerInterface } from "../contracts/ViewControllerInterface.js";
|
|
24
|
+
import type { ViewInterface } from "../contracts/ViewInterface.js";
|
|
25
|
+
import type { ViewManagerInterface, ActiveViewInfo } from "../contracts/ViewManagerInterface.js";
|
|
26
|
+
import { BlockManager, BlockManagerService } from "../services/BlockManager.js";
|
|
27
|
+
import { SectionManager, SectionManagerService } from "../services/SectionManager.js";
|
|
28
|
+
import devtools from "../devtools/hook.js";
|
|
29
|
+
import { BlockOutlet } from "../elements/BlockOutlet.js";
|
|
30
|
+
import { PageCacheService, PageCacheEntry, detachWrapperDOM } from "../services/PageCache.js";
|
|
31
|
+
import { Html } from "../elements/Html.js";
|
|
32
|
+
import { hasData } from "../helpers/utils.js";
|
|
33
|
+
import { activateView, claimHydratedView, commitView, flushView } from "../helpers/view.js";
|
|
34
|
+
import markerRegistry from "../services/MarkerRegistry.js";
|
|
35
|
+
import logger from "../services/LoggerService.js";
|
|
36
|
+
import { StoreService } from "../services/StoreService.js";
|
|
37
|
+
import { View } from "./View.js";
|
|
38
|
+
import { InitMode, InitModes } from "../contracts/common.js";
|
|
39
|
+
import { OOTEnum } from "../types/utils.js";
|
|
40
|
+
import { WrapperInterface } from "../contracts/ElementInterface.js";
|
|
41
|
+
import { app } from "../helpers/app.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SSR boot info — server nhúng sau khi render xong (RUNTIME_CONTRACT §6).
|
|
45
|
+
* `view` = registry path của page entry, `viewId` = id server đã dùng để prefix
|
|
46
|
+
* markers/classes của page. viewId của layout chain được client discover từ DOM.
|
|
47
|
+
*/
|
|
48
|
+
export type SSRBootInfo = {
|
|
49
|
+
view: string;
|
|
50
|
+
viewId: string;
|
|
51
|
+
container?: string;
|
|
52
|
+
};
|
|
36
53
|
|
|
37
54
|
type RenderPageViewSuccess = {
|
|
38
55
|
type: 'success';
|
|
@@ -41,6 +58,8 @@ type RenderPageViewSuccess = {
|
|
|
41
58
|
result: ViewInterface | unknown;
|
|
42
59
|
superView: ViewInterface | null;
|
|
43
60
|
finalView: ViewInterface;
|
|
61
|
+
/** Origin → outermost: [page, inner layout, ..., root layout]. */
|
|
62
|
+
chain: ViewInterface[];
|
|
44
63
|
};
|
|
45
64
|
|
|
46
65
|
type RenderPageViewError = {
|
|
@@ -50,12 +69,23 @@ type RenderPageViewError = {
|
|
|
50
69
|
result: null;
|
|
51
70
|
superView: null;
|
|
52
71
|
finalView: ViewInterface;
|
|
72
|
+
chain: ViewInterface[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
type RenderPageViewCancelled = {
|
|
76
|
+
type: 'cancelled';
|
|
77
|
+
message: string;
|
|
78
|
+
view: ViewInterface;
|
|
79
|
+
result: null;
|
|
80
|
+
superView: null;
|
|
81
|
+
finalView: ViewInterface;
|
|
82
|
+
chain: ViewInterface[];
|
|
53
83
|
};
|
|
54
84
|
|
|
55
|
-
type RenderPageViewResult = RenderPageViewSuccess | RenderPageViewError;
|
|
85
|
+
type RenderPageViewResult = RenderPageViewSuccess | RenderPageViewError | RenderPageViewCancelled;
|
|
56
86
|
|
|
57
|
-
function isRenderableObject(result: unknown): result is {
|
|
58
|
-
return typeof result === 'object' && result !== null && '
|
|
87
|
+
function isRenderableObject(result: unknown): result is { saoType: string } {
|
|
88
|
+
return typeof result === 'object' && result !== null && 'saoType' in result;
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
export class ViewManager implements ViewManagerInterface {
|
|
@@ -63,15 +93,16 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
63
93
|
private App: ApplicationInterface | null = null;
|
|
64
94
|
|
|
65
95
|
private systemData: Record<string, any> = {}; // For internal use, not exposed to views
|
|
96
|
+
private contextRevision: string | null = null;
|
|
97
|
+
private contextViews: string | null = null;
|
|
66
98
|
|
|
67
99
|
/** ROOT DOM container where views mount */
|
|
68
100
|
private container: HTMLElement | null = null;
|
|
69
|
-
private rootElement:
|
|
101
|
+
private rootElement: HtmlInterface | null = null; // Html wrapper for the root container
|
|
70
102
|
/** View module registry: name → factory or async loader */
|
|
71
103
|
private viewRegistry: Record<string, ((...args: any[]) => any) | (() => Promise<any>)> = {};
|
|
72
104
|
|
|
73
105
|
/** Currently mounted views (keyed by path) */
|
|
74
|
-
private activeViews: Map<string, ActiveViewInfo> = new Map();
|
|
75
106
|
|
|
76
107
|
/** The outermost active view (layout or page) */
|
|
77
108
|
private currentView: ActiveViewInfo | null = null;
|
|
@@ -80,14 +111,28 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
80
111
|
private currentLayoutPath: string | null = null;
|
|
81
112
|
|
|
82
113
|
private currentLayoutView: ViewInterface | null = null; // Store the current layout view instance for reuse
|
|
114
|
+
/** Mounted layouts ordered outermost → innermost. */
|
|
115
|
+
private currentLayoutChain: ViewInterface[] = [];
|
|
83
116
|
private currentPageView: ViewInterface | null = null; // Store the current page view instance for reference in blocks and sections
|
|
84
117
|
private currentViewType: 'view' | 'layout' | null = null; // Track whether the current view is a page or layout for correct lifecycle handling
|
|
85
118
|
|
|
119
|
+
/**
|
|
120
|
+
* SSR boot info — view entry (page) + viewId server đã render. Set ở init()
|
|
121
|
+
* từ config.ssr (đọc từ DOM lúc boot). Router consume 1 lần cho route đầu
|
|
122
|
+
* tiên → hydrateView; các route sau là CSR (SPA takeover).
|
|
123
|
+
*/
|
|
124
|
+
private ssrBoot: SSRBootInfo | null = null;
|
|
125
|
+
/** Standalone SPA: Laravel data requests may live on a different host. */
|
|
126
|
+
private dataEndpoint: string | null = null;
|
|
127
|
+
private fetchOptions: Record<string, any> = {};
|
|
128
|
+
|
|
129
|
+
/** Exact Page/Layout instance relationships exported by Blade for hydration. */
|
|
130
|
+
private ssrViewData: Record<string, any> = {};
|
|
131
|
+
|
|
86
132
|
|
|
87
133
|
/** Current layout view info — reused if same layout */
|
|
88
134
|
private currentLayout: ActiveViewInfo | null = null;
|
|
89
135
|
|
|
90
|
-
private cachedLayouts: Map<string, ViewInterface> = new Map(); // Cache for previously mounted layouts
|
|
91
136
|
|
|
92
137
|
/** All views in the current mount chain (outermost → innermost) */
|
|
93
138
|
private viewStack: ViewInterface[] = [];
|
|
@@ -98,18 +143,82 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
98
143
|
/** Render counter for debugging */
|
|
99
144
|
private renderCount = 0;
|
|
100
145
|
|
|
146
|
+
/** Invalidates fire-and-forget render work when a newer navigation begins. */
|
|
147
|
+
/**
|
|
148
|
+
* Số lượt render `@await` đang bay.
|
|
149
|
+
*
|
|
150
|
+
* Nhánh "có prerender" cố ý fire-and-forget: nó trả skeleton về NGAY rồi
|
|
151
|
+
* mới fetch, nên người gọi không có promise nào để đợi và không có cách
|
|
152
|
+
* nào biết nội dung thật đã vào chưa. Đếm ở đây để {@link isSettled} trả
|
|
153
|
+
* lời được câu đó — công cụ đo, chụp ảnh trang, hay test parity SSR↔CSR
|
|
154
|
+
* đều cần một tín hiệu THẬT thay vì đoán bằng "DOM đứng yên": trang đang
|
|
155
|
+
* chờ dữ liệu thì DOM cũng đứng yên y như đã xong.
|
|
156
|
+
*/
|
|
157
|
+
private pendingAsyncRenders = 0;
|
|
158
|
+
|
|
159
|
+
/** Không còn render `@await` nào đang bay — nội dung thật đã vào DOM. */
|
|
160
|
+
get isSettled(): boolean {
|
|
161
|
+
return this.pendingAsyncRenders === 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private navigationGeneration = 0;
|
|
165
|
+
|
|
101
166
|
public store: StoreService = StoreService.instance("ViewManager");
|
|
102
167
|
|
|
103
168
|
public blockManager: BlockManagerService = BlockManager;
|
|
169
|
+
public sectionManager: SectionManagerService = SectionManager;
|
|
104
170
|
|
|
105
171
|
constructor(app?: ApplicationInterface) {
|
|
106
172
|
if (app) this.App = app;
|
|
173
|
+
|
|
174
|
+
// Layout instance sống ở CẢ HAI nơi: PageCache (DOM detached) + store
|
|
175
|
+
// (để extendView trả lại đúng instance). Entry bị evict/expire → instance
|
|
176
|
+
// đã destroy → PHẢI gỡ khỏi store, nếu không extendView trả instance chết.
|
|
177
|
+
this.pageCache.onEvict = (entry) => {
|
|
178
|
+
for (const v of entry.views) {
|
|
179
|
+
const path = v.__ctrl__?.path;
|
|
180
|
+
if (path && this.store.has(path) && this.store.get(path) === v) {
|
|
181
|
+
this.store.remove?.(path);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
107
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Kiểm tra một view có đang mount (active) không.
|
|
188
|
+
* Dùng để guard duplicate mount hoặc kiểm tra trạng thái từ bên ngoài.
|
|
189
|
+
*/
|
|
108
190
|
isViewMounted(path: string): boolean {
|
|
109
|
-
|
|
191
|
+
if (this.currentPageView?.__ctrl__.path === path) return true;
|
|
192
|
+
return this.currentLayoutChain.some(v => v.__ctrl__.path === path);
|
|
110
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Invalidate async render/fetch work owned by the current navigation. */
|
|
196
|
+
cancelNavigation(): void {
|
|
197
|
+
this.navigationGeneration++;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Destroy ViewManager hoàn toàn — dọn sạch mọi view, DOM, state.
|
|
202
|
+
* Gọi khi teardown app (hot reload, test cleanup, unmount root).
|
|
203
|
+
*/
|
|
111
204
|
destroy(): void {
|
|
112
|
-
|
|
205
|
+
this.navigationGeneration++;
|
|
206
|
+
// 1. Stop và destroy tất cả views đang active
|
|
207
|
+
this.unmountAll();
|
|
208
|
+
// 2. Dọn PageCache
|
|
209
|
+
this.pageCache.clear();
|
|
210
|
+
// 3. Reset registry và internal state
|
|
211
|
+
this.viewRegistry = {};
|
|
212
|
+
this.rootElement = null;
|
|
213
|
+
this.container = null;
|
|
214
|
+
this._isInitialized = false;
|
|
215
|
+
this.renderCount = 0;
|
|
216
|
+
this.store.clear();
|
|
217
|
+
// MarkerRegistry là singleton TOÀN CỤC ở tầng module — không thuộc view
|
|
218
|
+
// nào nên không teardown nào khác chạm tới. Teardown app (hot reload,
|
|
219
|
+
// unmount root, dọn giữa các test) là chỗ duy nhất dọn được nó.
|
|
220
|
+
markerRegistry.clear();
|
|
221
|
+
logger.info('[ViewManager] destroyed.');
|
|
113
222
|
}
|
|
114
223
|
|
|
115
224
|
|
|
@@ -160,7 +269,28 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
160
269
|
/**
|
|
161
270
|
* Initialize the ViewManager.
|
|
162
271
|
*/
|
|
163
|
-
init(config?: { container?: HTMLElement | string; registry?: Record<string, any> }): void {
|
|
272
|
+
init(config?: { container?: HTMLElement | string; registry?: Record<string, any>; ssr?: SSRBootInfo | null; systemData?: Record<string, any>; ssrData?: Record<string, any>; revision?: string; contextViews?: string; dataEndpoint?: string; fetchOptions?: Record<string, any> }): void {
|
|
273
|
+
if (typeof config?.dataEndpoint === 'string') this.dataEndpoint = config.dataEndpoint;
|
|
274
|
+
if (config?.fetchOptions) this.fetchOptions = { ...config.fetchOptions };
|
|
275
|
+
// SSR boot info (server-rendered): chứa view entry + viewId để route đầu
|
|
276
|
+
// tiên gọi hydrateView thay vì mountView. Xem RUNTIME_CONTRACT §6 (boot).
|
|
277
|
+
if (config?.ssr && config.ssr.view && config.ssr.viewId) {
|
|
278
|
+
this.ssrBoot = config.ssr;
|
|
279
|
+
}
|
|
280
|
+
// systemData (server __layout__/__base__/__page__/...): spread vào mọi
|
|
281
|
+
// view factory ở view() để compiled view resolve superView/namespace.
|
|
282
|
+
if (config?.systemData && typeof config.systemData === 'object') {
|
|
283
|
+
this.systemData = { ...this.systemData, ...config.systemData };
|
|
284
|
+
}
|
|
285
|
+
if (typeof config?.revision === 'string') {
|
|
286
|
+
this.contextRevision = config.revision;
|
|
287
|
+
}
|
|
288
|
+
if (typeof config?.contextViews === 'string') {
|
|
289
|
+
this.contextViews = config.contextViews;
|
|
290
|
+
}
|
|
291
|
+
if (config?.ssrData && typeof config.ssrData === 'object') {
|
|
292
|
+
this.ssrViewData = config.ssrData;
|
|
293
|
+
}
|
|
164
294
|
if (config?.container) {
|
|
165
295
|
if (typeof config.container === 'string') {
|
|
166
296
|
const found = document.querySelector(config.container);
|
|
@@ -181,58 +311,187 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
181
311
|
ctx: this,
|
|
182
312
|
tagName: this.container.tagName.toLowerCase(),
|
|
183
313
|
element: this.container,
|
|
184
|
-
initMode:
|
|
314
|
+
initMode: InitModes.HYDRATE,
|
|
315
|
+
childrenFactory: () => [],
|
|
185
316
|
});
|
|
186
317
|
if (config?.registry) {
|
|
187
318
|
this.setViewRegistry(config.registry);
|
|
188
319
|
}
|
|
189
320
|
this._isInitialized = true;
|
|
321
|
+
devtools.attach(this);
|
|
190
322
|
}
|
|
191
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Phương án CUỐI khi không error boundary nào xử lý (xem ViewController.onError):
|
|
326
|
+
* ghi đè cả container. Dựng bằng DOM API + textContent — message/details có thể
|
|
327
|
+
* mang nội dung từ server/URL, nội suy vào innerHTML là đường tiêm HTML.
|
|
328
|
+
*/
|
|
192
329
|
showError(message: string, details?: any) {
|
|
193
330
|
logger.error(message, details);
|
|
194
|
-
if (this.container)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
331
|
+
if (!this.container) return;
|
|
332
|
+
|
|
333
|
+
const box = document.createElement('div');
|
|
334
|
+
box.style.cssText = 'padding:20px;background:#fee;color:#900;border:1px solid #900';
|
|
335
|
+
|
|
336
|
+
const title = document.createElement('h2');
|
|
337
|
+
title.textContent = 'Error';
|
|
338
|
+
box.appendChild(title);
|
|
339
|
+
|
|
340
|
+
const text = document.createElement('p');
|
|
341
|
+
text.textContent = message;
|
|
342
|
+
box.appendChild(text);
|
|
343
|
+
|
|
344
|
+
if (details) {
|
|
345
|
+
const pre = document.createElement('pre');
|
|
346
|
+
pre.style.cssText = 'white-space:pre-wrap;background:#fdd;padding:10px;border:1px solid #900';
|
|
347
|
+
let serialized: string;
|
|
348
|
+
try {
|
|
349
|
+
serialized = JSON.stringify(details, null, 2) ?? String(details);
|
|
350
|
+
} catch {
|
|
351
|
+
serialized = String(details); // circular / getter throw
|
|
352
|
+
}
|
|
353
|
+
pre.textContent = serialized;
|
|
354
|
+
box.appendChild(pre);
|
|
200
355
|
}
|
|
356
|
+
|
|
357
|
+
this.container.replaceChildren(box);
|
|
201
358
|
}
|
|
202
359
|
|
|
203
360
|
// ─── View Loading ───────────────────────────────────────────
|
|
361
|
+
/**
|
|
362
|
+
* Khoá registry thật sự dùng cho `name`.
|
|
363
|
+
*
|
|
364
|
+
* Server có ThemeAwareViewFinder nên một view theme không đè vẫn render
|
|
365
|
+
* được từ base. Client không có đường đó: `__layout__` là tiền tố của CẢ
|
|
366
|
+
* context, nên `@extends(__layout__ + "workspace")` sinh khoá
|
|
367
|
+
* `themes.{slug}.layouts.workspace` dù registry chỉ có bản của base.
|
|
368
|
+
* Server gửi kèm cặp `__view_fallback_from__/to__` trong systemData; ở đây
|
|
369
|
+
* chỉ đổi tiền tố khi khoá gốc không có.
|
|
370
|
+
*/
|
|
371
|
+
private resolveRegistryKey(name: string): string {
|
|
372
|
+
if (Object.prototype.hasOwnProperty.call(this.viewRegistry, name)) return name;
|
|
373
|
+
|
|
374
|
+
const from = (this.systemData as any)?.__view_fallback_from__;
|
|
375
|
+
const to = (this.systemData as any)?.__view_fallback_to__;
|
|
376
|
+
if (typeof from !== 'string' || typeof to !== 'string' || !from || !to) return name;
|
|
377
|
+
|
|
378
|
+
const rebased = name === from
|
|
379
|
+
? to
|
|
380
|
+
: (name.startsWith(from + '.') ? to + name.slice(from.length) : name);
|
|
381
|
+
|
|
382
|
+
return Object.prototype.hasOwnProperty.call(this.viewRegistry, rebased) ? rebased : name;
|
|
383
|
+
}
|
|
384
|
+
|
|
204
385
|
hasView(name: string): boolean {
|
|
205
|
-
return Object.prototype.hasOwnProperty.call(this.viewRegistry, name);
|
|
386
|
+
return Object.prototype.hasOwnProperty.call(this.viewRegistry, this.resolveRegistryKey(name));
|
|
206
387
|
}
|
|
207
388
|
|
|
208
389
|
exists(name: string): boolean {
|
|
209
390
|
return this.hasView(name);
|
|
210
391
|
}
|
|
211
392
|
|
|
212
|
-
|
|
393
|
+
/**
|
|
394
|
+
* generateViewId — tạo unique ID cho mỗi view instance.
|
|
395
|
+
*
|
|
396
|
+
* Compiled output gọi:
|
|
397
|
+
* const __VIEW_ID__ = __data__.__SSR_VIEW_ID__ || App.View.generateViewId();
|
|
398
|
+
*
|
|
399
|
+
* Dùng trong constructor của compiled View class để gán viewId
|
|
400
|
+
* (tránh hai instance cùng view path dùng chung ID gây clobber registry).
|
|
401
|
+
*/
|
|
402
|
+
generateViewId(): string {
|
|
403
|
+
return `c${(this.viewSeq++).toString(36)}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Bộ đếm cho view do CLIENT tạo (CSR / điều hướng SPA).
|
|
408
|
+
*
|
|
409
|
+
* Tiền tố 'c' — server dùng 'v' (ViewContextServiceProvider) — nên hai nguồn
|
|
410
|
+
* id không thể trùng nhau theo cấu trúc, không phải nhờ may mắn ngẫu nhiên.
|
|
411
|
+
* View hydrate từ SSR KHÔNG đi qua đây: nó nhận thẳng __SSR_VIEW_ID__ của
|
|
412
|
+
* server, nên hai phía luôn khớp.
|
|
413
|
+
*/
|
|
414
|
+
private viewSeq = 0;
|
|
415
|
+
|
|
416
|
+
/** Factory đã unwrap của các view lazy — tránh await + unwrap lại mỗi lần navigate. */
|
|
417
|
+
private resolvedFactories: Map<string, (...args: any[]) => any> = new Map();
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Tạo View instance. Hỗ trợ registry lazy (`() => import('./x.js')`) — dùng
|
|
421
|
+
* cho view cấp ROUTE (mountView/hydrateView). `@include`/`@extends` chạy
|
|
422
|
+
* trong render tree đồng bộ nên dùng `resolveViewSync()`.
|
|
423
|
+
*/
|
|
424
|
+
async view(name: string, data: Record<string, any>, cache: boolean): Promise<any> {
|
|
213
425
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
426
|
+
// Khoá store phải là khoá registry ĐÃ RESOLVE, không phải tên được
|
|
427
|
+
// yêu cầu. Khi theme đang bật, `__layout__` là tiền tố của cả context
|
|
428
|
+
// nên tên yêu cầu là `themes.{slug}.layouts.docs` còn view thật lấy
|
|
429
|
+
// từ base: `ctrl.path` = `web.layouts.docs`. Hai chỗ dọn store
|
|
430
|
+
// (`pageCache.onEvict` và `destroyLayoutView`) đều xoá theo
|
|
431
|
+
// `ctrl.path`, nên lưu theo tên yêu cầu là instance ĐÃ DESTROY nằm
|
|
432
|
+
// lại trong store vĩnh viễn — lần điều hướng sau `extendView` trả về
|
|
433
|
+
// nó và `render()` trả rỗng. Không bật theme thì hai khoá trùng nhau
|
|
434
|
+
// nên bug này ẩn hoàn toàn.
|
|
435
|
+
const key = this.resolveRegistryKey(name);
|
|
436
|
+
const cached = this.viewFromStore(key, data, cache);
|
|
437
|
+
if (cached) return cached;
|
|
438
|
+
const factory = this.resolvedFactories.get(key) ?? this.viewRegistry[key];
|
|
439
|
+
if (!factory || typeof factory !== 'function') {
|
|
440
|
+
logger.error(`View "${name}" not found in registry.`);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const systemData = this.buildSystemData();
|
|
445
|
+
let view = factory(data ?? {}, systemData);
|
|
446
|
+
|
|
447
|
+
// Registry lazy → Promise<module>. Await, chuẩn hoá, cache factory đã
|
|
448
|
+
// unwrap để lần navigate sau không phải await/unwrap lại.
|
|
449
|
+
if (view && typeof view.then === 'function') {
|
|
450
|
+
const lazyFactory = this.unwrapLazyFactory(await view);
|
|
451
|
+
if (!lazyFactory) {
|
|
452
|
+
logger.error(`Lazy view "${name}" did not resolve to a factory or View.`);
|
|
453
|
+
return null;
|
|
218
454
|
}
|
|
219
|
-
|
|
455
|
+
// Cache theo `key` cho khớp chỗ đọc ở trên; theo `name` thì
|
|
456
|
+
// lần sau luôn trượt cache khi theme đang bật.
|
|
457
|
+
this.resolvedFactories.set(key, lazyFactory);
|
|
458
|
+
view = lazyFactory(data ?? {}, systemData);
|
|
220
459
|
}
|
|
221
|
-
|
|
460
|
+
return this.finalizeView(key, view, cache);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
// Gồm cả chunk 404 / mạng lỗi khi import() — không để throw ra Router.
|
|
464
|
+
logger.error(`Error loading view ${name}:`, err);
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Bản đồng bộ cho `@include`/`@extends` — render tree không await được.
|
|
471
|
+
* View lazy CHƯA preload → null + hướng dẫn, thay vì trả Promise làm vỡ
|
|
472
|
+
* ngầm ở `view.__ctrl__` phía sau.
|
|
473
|
+
*/
|
|
474
|
+
resolveViewSync(name: string, data: Record<string, any>, cache: boolean): any {
|
|
475
|
+
try {
|
|
476
|
+
// Xem ghi chú ở view(): khoá store bám khoá registry đã resolve.
|
|
477
|
+
const key = this.resolveRegistryKey(name);
|
|
478
|
+
const cached = this.viewFromStore(key, data, cache);
|
|
479
|
+
if (cached) return cached;
|
|
480
|
+
const factory = this.resolvedFactories.get(key) ?? this.viewRegistry[key];
|
|
222
481
|
if (!factory || typeof factory !== 'function') {
|
|
223
482
|
logger.error(`View "${name}" not found in registry.`);
|
|
224
483
|
return null;
|
|
225
484
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
485
|
+
|
|
486
|
+
const view = factory(data ?? {}, this.buildSystemData());
|
|
487
|
+
if (view && typeof view.then === 'function') {
|
|
488
|
+
logger.error(
|
|
489
|
+
`View "${name}" là lazy nhưng được dùng qua @include/@extends — render tree đồng bộ ` +
|
|
490
|
+
`không await được. Gọi App.View.preloadView("${name}") trước, hoặc để view này eager trong registry.`
|
|
491
|
+
);
|
|
229
492
|
return null;
|
|
230
493
|
}
|
|
231
|
-
|
|
232
|
-
this.store.set(name, view);
|
|
233
|
-
}
|
|
234
|
-
return view;
|
|
235
|
-
|
|
494
|
+
return this.finalizeView(key, view, cache);
|
|
236
495
|
}
|
|
237
496
|
catch (err) {
|
|
238
497
|
logger.error(`Error loading view ${name}:`, err);
|
|
@@ -240,6 +499,54 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
240
499
|
}
|
|
241
500
|
}
|
|
242
501
|
|
|
502
|
+
/** Nạp trước một view lazy để `@include`/`@extends` dùng được đồng bộ sau đó. */
|
|
503
|
+
async preloadView(name: string): Promise<boolean> {
|
|
504
|
+
if (this.resolvedFactories.has(name)) return true;
|
|
505
|
+
const view = await this.view(name, {}, false);
|
|
506
|
+
if (!view) return false;
|
|
507
|
+
view.__ctrl__?.destroy?.(); // instance dò đường — chỉ cần factory đã cache
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private viewFromStore(name: string, data: Record<string, any>, cache: boolean): any {
|
|
512
|
+
if (!cache || !this.store.has(name)) return null;
|
|
513
|
+
const cachedView = this.store.get<View>(name);
|
|
514
|
+
if (hasData(data)) cachedView?.__ctrl__.updateData(data);
|
|
515
|
+
return cachedView;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Truyền data PHẲNG cho factory: compiled factory nhận __data__ là chính
|
|
520
|
+
* object data (đọc __data__.__SSR_VIEW_ID__, setup({data:__data__})).
|
|
521
|
+
* Trước đây bọc { data } → __data__.__SSR_VIEW_ID__ = undefined và ctrl.data
|
|
522
|
+
* bị lồng (vỡ route-param). Xem docs/HYDRATION.md §9.2.
|
|
523
|
+
*/
|
|
524
|
+
private buildSystemData(): Record<string, any> {
|
|
525
|
+
return { App: this.App, View: this, ...this.systemData };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private finalizeView(name: string, view: any, cache: boolean): any {
|
|
529
|
+
if (!view) {
|
|
530
|
+
logger.error(`Factory for view "${name}" did not return a valid view instance.`);
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
if (cache) this.store.set(name, view);
|
|
534
|
+
return view;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Chuẩn hoá kết quả resolve của registry lazy về factory `(data, sys) => View`.
|
|
539
|
+
* Chấp nhận: module namespace (`{ default: factory }`), factory trần, hoặc
|
|
540
|
+
* View instance — người viết registry không phải tự `.then(m => m.default)`.
|
|
541
|
+
*/
|
|
542
|
+
private unwrapLazyFactory(resolved: any): ((...args: any[]) => any) | null {
|
|
543
|
+
if (!resolved) return null;
|
|
544
|
+
if (typeof resolved === 'function') return resolved;
|
|
545
|
+
if (typeof resolved.default === 'function') return resolved.default;
|
|
546
|
+
if (resolved.__ctrl__) return () => resolved; // đã là View instance
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
243
550
|
private createRenderPageViewError(view: ViewInterface, renderLevel: number, message?: string): RenderPageViewError {
|
|
244
551
|
return {
|
|
245
552
|
type: 'error',
|
|
@@ -250,14 +557,32 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
250
557
|
result: null,
|
|
251
558
|
superView: null,
|
|
252
559
|
finalView: view,
|
|
560
|
+
chain: [view],
|
|
253
561
|
};
|
|
254
562
|
}
|
|
255
563
|
|
|
564
|
+
private createRenderPageViewCancelled(view: ViewInterface): RenderPageViewCancelled {
|
|
565
|
+
return {
|
|
566
|
+
type: 'cancelled',
|
|
567
|
+
message: '',
|
|
568
|
+
view,
|
|
569
|
+
result: null,
|
|
570
|
+
superView: null,
|
|
571
|
+
finalView: view,
|
|
572
|
+
chain: [view],
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private isNavigationCurrent(generation: number): boolean {
|
|
577
|
+
return generation === this.navigationGeneration;
|
|
578
|
+
}
|
|
579
|
+
|
|
256
580
|
private createRenderPageViewSuccess(
|
|
257
581
|
view: ViewInterface,
|
|
258
582
|
result: ViewInterface | unknown,
|
|
259
583
|
superView: ViewInterface | null,
|
|
260
|
-
finalView: ViewInterface
|
|
584
|
+
finalView: ViewInterface,
|
|
585
|
+
chain: ViewInterface[] = [view],
|
|
261
586
|
): RenderPageViewSuccess {
|
|
262
587
|
return {
|
|
263
588
|
type: 'success',
|
|
@@ -266,11 +591,12 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
266
591
|
result,
|
|
267
592
|
superView,
|
|
268
593
|
finalView,
|
|
594
|
+
chain,
|
|
269
595
|
};
|
|
270
596
|
}
|
|
271
597
|
|
|
272
598
|
private getRenderResultType(result: unknown): OOTEnum {
|
|
273
|
-
return isRenderableObject(result) ? result.
|
|
599
|
+
return isRenderableObject(result) ? result.saoType as OOTEnum : OOTEnum.UNKNOWN;
|
|
274
600
|
}
|
|
275
601
|
|
|
276
602
|
|
|
@@ -281,14 +607,23 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
281
607
|
mountRoot: HtmlInterface | null = null,
|
|
282
608
|
initMode: InitMode = InitModes.CREATE,
|
|
283
609
|
cache: boolean = false,
|
|
284
|
-
renderLevel: number = 0
|
|
610
|
+
renderLevel: number = 0,
|
|
611
|
+
navigationGeneration: number = this.navigationGeneration,
|
|
285
612
|
): Promise<RenderPageViewResult> {
|
|
613
|
+
if (!this.isNavigationCurrent(navigationGeneration)) {
|
|
614
|
+
return this.createRenderPageViewCancelled(view);
|
|
615
|
+
}
|
|
286
616
|
const ctrl = view.__ctrl__;
|
|
287
617
|
|
|
288
618
|
if (hasData(data)) {
|
|
289
619
|
ctrl.updateData(data);
|
|
290
620
|
}
|
|
291
621
|
|
|
622
|
+
// Truyền initMode vào controller TRƯỚC khi render —
|
|
623
|
+
// để tất cả elements con (Html, Wrapper, Reactive, Output...)
|
|
624
|
+
// biết mình đang ở chế độ hydrate hay create.
|
|
625
|
+
ctrl.initMode = initMode;
|
|
626
|
+
|
|
292
627
|
const result = method === 'render'
|
|
293
628
|
? ctrl.render()
|
|
294
629
|
: (ctrl.prerender ? ctrl.prerender() : null);
|
|
@@ -305,17 +640,58 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
305
640
|
}
|
|
306
641
|
|
|
307
642
|
if (resultType === OOTEnum.VIEW) {
|
|
643
|
+
const superView = result as ViewInterface;
|
|
644
|
+
|
|
645
|
+
// CSR navigation may resolve @extends to the exact Layout instance
|
|
646
|
+
// that is already active. Its DOM, outlets and lifecycle are retained,
|
|
647
|
+
// so rendering it again only recreates declarations and can repeat
|
|
648
|
+
// user side effects. Reuse the already-resolved outer chain instead.
|
|
649
|
+
// Hydration must still render every level to claim Blade's SSR DOM.
|
|
650
|
+
if (initMode === InitModes.CREATE) {
|
|
651
|
+
const activeLayoutIndex = this.currentLayoutChain.indexOf(superView);
|
|
652
|
+
if (activeLayoutIndex >= 0) {
|
|
653
|
+
const reusedChain = this.currentLayoutChain
|
|
654
|
+
.slice(0, activeLayoutIndex + 1)
|
|
655
|
+
.reverse();
|
|
656
|
+
return this.createRenderPageViewSuccess(
|
|
657
|
+
view,
|
|
658
|
+
superView,
|
|
659
|
+
superView,
|
|
660
|
+
reusedChain[reusedChain.length - 1] ?? superView,
|
|
661
|
+
[view, ...reusedChain],
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Hydrate: gán viewId của layout TRƯỚC khi render nó ──────────
|
|
667
|
+
// Page gọi extendView(layoutPath, {}) → layout tự sinh viewId ngẫu
|
|
668
|
+
// nhiên, KHÔNG khớp viewId server đã render. Phải gán đúng id ở đây
|
|
669
|
+
// (trước render → trước khi Wrapper layout capture ctx.viewId), bằng
|
|
670
|
+
// cách đọc lại từ DOM view marker <!--s:v:{id}-s--> server để lại.
|
|
671
|
+
if (initMode === InitModes.HYDRATE) {
|
|
672
|
+
const discovered = this.discoverChainViewId(
|
|
673
|
+
mountRoot,
|
|
674
|
+
new Set([ctrl.viewId]),
|
|
675
|
+
superView.__ctrl__.path,
|
|
676
|
+
ctrl.viewId,
|
|
677
|
+
);
|
|
678
|
+
if (discovered) {
|
|
679
|
+
superView.__ctrl__.viewId = discovered;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
308
683
|
const superResult = await this.renderPageView(
|
|
309
|
-
|
|
684
|
+
superView, {}, mountRoot, initMode, cache, renderLevel + 1, navigationGeneration
|
|
310
685
|
);
|
|
311
|
-
if (superResult.type
|
|
686
|
+
if (superResult.type !== 'success') {
|
|
312
687
|
return { ...superResult, view };
|
|
313
688
|
}
|
|
314
689
|
return this.createRenderPageViewSuccess(
|
|
315
690
|
view,
|
|
316
|
-
|
|
691
|
+
superView,
|
|
317
692
|
superResult.view,
|
|
318
|
-
superResult.finalView ??
|
|
693
|
+
superResult.finalView ?? superView,
|
|
694
|
+
[view, ...superResult.chain],
|
|
319
695
|
);
|
|
320
696
|
}
|
|
321
697
|
|
|
@@ -323,15 +699,67 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
323
699
|
`View "${ctrl.path}" returned invalid content (type: ${resultType}) from ${method}().`);
|
|
324
700
|
}
|
|
325
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Discover viewId của một layout/superView từ SSR DOM (hydration).
|
|
704
|
+
*
|
|
705
|
+
* Page gọi extendView() KHÔNG truyền viewId của layout (server tự sinh id),
|
|
706
|
+
* nên client lấy lại id từ quan hệ instance mà Blade export. Nếu output cũ
|
|
707
|
+
* chưa có quan hệ này, client mới discover từ metadata/marker trong DOM.
|
|
708
|
+
*
|
|
709
|
+
* Nested chain ưu tiên quan hệ parent chính xác trong APP_CONFIGS.view.ssrData,
|
|
710
|
+
* sau đó mới tới `data-view-name`/`data-view-id`; marker scan là fallback.
|
|
711
|
+
*/
|
|
712
|
+
private discoverChainViewId(
|
|
713
|
+
mountRoot: HtmlInterface | null,
|
|
714
|
+
excludeIds: Set<string>,
|
|
715
|
+
viewPath?: string,
|
|
716
|
+
parentViewId?: string,
|
|
717
|
+
): string | null {
|
|
718
|
+
if (viewPath) {
|
|
719
|
+
const instances = this.ssrViewData[viewPath]?.instances;
|
|
720
|
+
if (instances && typeof instances === 'object') {
|
|
721
|
+
for (const [instanceId, instance] of Object.entries(instances)) {
|
|
722
|
+
const id = (instance as any)?.viewId ?? instanceId;
|
|
723
|
+
if (id && !excludeIds.has(id)
|
|
724
|
+
&& (!parentViewId || (instance as any)?.parent?.id === parentViewId)) {
|
|
725
|
+
return id;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const records = document.querySelectorAll('script[data-ref="view-data"][data-view-id]');
|
|
730
|
+
for (const record of records) {
|
|
731
|
+
const id = record.getAttribute('data-view-id');
|
|
732
|
+
if (record.getAttribute('data-view-name') === viewPath
|
|
733
|
+
&& id && !excludeIds.has(id)) return id;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const root = mountRoot?.getElement?.() ?? this.rootElement?.getElement?.();
|
|
737
|
+
if (!root) return null;
|
|
738
|
+
|
|
739
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
|
740
|
+
let node: Comment | null;
|
|
741
|
+
while ((node = walker.nextNode() as Comment | null)) {
|
|
742
|
+
const parsed = markerRegistry.parseComment(node.nodeValue ?? '');
|
|
743
|
+
if (parsed && parsed.tag === 'view' && !parsed.isClose && !excludeIds.has(parsed.id)) {
|
|
744
|
+
return parsed.id;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
326
750
|
async renderPageView(
|
|
327
|
-
view: ViewInterface,
|
|
328
|
-
data: Record<string, any>,
|
|
329
|
-
mountRoot: HtmlInterface | null = null,
|
|
330
|
-
initMode: InitMode = InitModes.CREATE,
|
|
331
|
-
cache: boolean = false,
|
|
332
|
-
renderLevel: number = 0
|
|
751
|
+
view: ViewInterface,
|
|
752
|
+
data: Record<string, any>,
|
|
753
|
+
mountRoot: HtmlInterface | null = null,
|
|
754
|
+
initMode: InitMode = InitModes.CREATE,
|
|
755
|
+
cache: boolean = false,
|
|
756
|
+
renderLevel: number = 0,
|
|
757
|
+
navigationGeneration: number = this.navigationGeneration,
|
|
333
758
|
): Promise<RenderPageViewResult> {
|
|
334
759
|
try {
|
|
760
|
+
if (!this.isNavigationCurrent(navigationGeneration)) {
|
|
761
|
+
return this.createRenderPageViewCancelled(view);
|
|
762
|
+
}
|
|
335
763
|
const ctrl = view.__ctrl__;
|
|
336
764
|
if (hasData(data)) {
|
|
337
765
|
ctrl.updateData(data);
|
|
@@ -341,43 +769,124 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
341
769
|
|
|
342
770
|
// ── Case 1: Không có async data → render ngay ──
|
|
343
771
|
if (!hasAsyncData) {
|
|
344
|
-
return this.callViewRenderFactory(view, 'render', data, mountRoot, initMode, cache, renderLevel);
|
|
772
|
+
return this.callViewRenderFactory(view, 'render', data, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ── Hydrate: SSR đã fetch + nhúng data rồi (qua __data__/ssrData) —
|
|
776
|
+
// KHÔNG fetch lại, KHÔNG hiện skeleton. Nhưng prerender() không chỉ
|
|
777
|
+
// là skeleton: với view @extends + @block, compiler gom phần block/
|
|
778
|
+
// section TĨNH (không phụ thuộc data await) VÀO PRERENDER, render()
|
|
779
|
+
// chỉ khai báo lại đúng (các) block phụ thuộc data (xem
|
|
780
|
+
// examples/sao/await.sao đã compile: prerender() có
|
|
781
|
+
// block-content(placeholder)+block-footer+section('sidebar'), còn
|
|
782
|
+
// render() chỉ có block-content — bỏ qua prerender() sẽ làm mất
|
|
783
|
+
// hẳn block-footer/sidebar khi hydrate).
|
|
784
|
+
// → Vẫn phải gọi prerender() để đăng ký các block/section tĩnh đó,
|
|
785
|
+
// rồi gọi NGAY render() (data đã có sẵn, không await fetch) — nó
|
|
786
|
+
// ghi đè contentRenderFactory của block phụ thuộc data (placeholder
|
|
787
|
+
// → nội dung thật) tại chỗ. Không có gì được mount ở bước nào cả
|
|
788
|
+
// (hydrate mode chỉ claim), nên không có flash trung gian.
|
|
789
|
+
if (initMode === InitModes.HYDRATE) {
|
|
790
|
+
if (config.hasPrerender) {
|
|
791
|
+
const prerenderResult = await this.callViewRenderFactory(view, 'prerender', data, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
792
|
+
if (prerenderResult.type === 'cancelled') return prerenderResult;
|
|
793
|
+
if (prerenderResult.type === 'error') {
|
|
794
|
+
logger.error(`Error prerendering view "${ctrl.path}" during hydrate:`, prerenderResult.message);
|
|
795
|
+
// Tiếp tục render() — thà thiếu block tĩnh còn hơn vỡ cả trang.
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return this.callViewRenderFactory(view, 'render', data, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
345
799
|
}
|
|
346
800
|
|
|
347
801
|
// ── Resolve fetch URL từ ViewController config hoặc fallback Router ──
|
|
348
802
|
const App = app() as ApplicationInterface;
|
|
349
803
|
const Http = App.Http;
|
|
350
804
|
const fetchConfig = config.fetch;
|
|
351
|
-
const fetchUrl = (config.hasAwaitData && fetchConfig?.url)? fetchConfig
|
|
805
|
+
const fetchUrl = (config.hasAwaitData && fetchConfig?.url) ? fetchConfig.url : resolveViewDataUrl(App.Router.getFullUrl(), this.dataEndpoint);
|
|
352
806
|
|
|
353
807
|
// ── Case 2: Có async + có prerender → prerender skeleton trước, fetch sau ──
|
|
354
808
|
if (config.hasPrerender) {
|
|
355
|
-
const
|
|
809
|
+
const renderGeneration = navigationGeneration;
|
|
810
|
+
const prerenderResult = await this.callViewRenderFactory(view, 'prerender', data, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
811
|
+
if (prerenderResult.type === 'cancelled') return prerenderResult;
|
|
356
812
|
if (prerenderResult.type === 'error') {
|
|
357
813
|
logger.error(`Error prerendering view "${ctrl.path}":`, prerenderResult.message);
|
|
358
814
|
// Fallback: render trực tiếp không qua prerender
|
|
359
|
-
return this.callViewRenderFactory(view, 'render', data, mountRoot, initMode, cache, renderLevel);
|
|
815
|
+
return this.callViewRenderFactory(view, 'render', data, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
360
816
|
}
|
|
361
817
|
|
|
362
|
-
// Fire-and-forget: fetch data
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
818
|
+
// Fire-and-forget: fetch data → re-render → swap skeleton → main
|
|
819
|
+
this.pendingAsyncRenders++;
|
|
820
|
+
Http.get(fetchUrl, this.fetchOptions).then(async (response: any) => {
|
|
821
|
+
// Route mới hoặc manager teardown đã bắt đầu: tuyệt đối không
|
|
822
|
+
// render/mount kết quả cũ trở lại root DOM.
|
|
823
|
+
if (renderGeneration !== this.navigationGeneration
|
|
824
|
+
|| ctrl.lifecycleState === 'destroyed') return;
|
|
825
|
+
|
|
826
|
+
const asyncData = this.extractAsyncData(response);
|
|
368
827
|
if (hasData(asyncData)) {
|
|
369
828
|
ctrl.updateData(asyncData);
|
|
370
829
|
}
|
|
371
|
-
|
|
830
|
+
|
|
831
|
+
// Render main content (kết quả sẽ nằm trong ctrl.mainElement)
|
|
832
|
+
const finalResult = await this.callViewRenderFactory(view, 'render', asyncData, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
833
|
+
if (renderGeneration !== this.navigationGeneration
|
|
834
|
+
|| ctrl.lifecycleState === 'destroyed') return;
|
|
372
835
|
if (finalResult.type === 'error') {
|
|
373
836
|
logger.error(`Error rendering view "${ctrl.path}" after async data fetch:`, finalResult.message);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (finalResult.superView) {
|
|
841
|
+
// ── @extends page: render() re-registered real block content
|
|
842
|
+
// via this.block(...), but never called this.wrapper() — so
|
|
843
|
+
// ctrl.mainElement/preloadElement (what the standalone swap
|
|
844
|
+
// below acts on) were never set. The already-mounted layout's
|
|
845
|
+
// outlet still shows the placeholder from the prerender-time
|
|
846
|
+
// this.block(...) call; push the new content into it the same
|
|
847
|
+
// way the initial mount does (mountViewBlocks clears the old
|
|
848
|
+
// content first, so the placeholder is removed as part of this).
|
|
849
|
+
// Block tìm outlet THEO TÊN nên viewId của page là đủ;
|
|
850
|
+
// section thì không — xem mountSectionsAcrossChain().
|
|
851
|
+
this.blockManager.mountViewBlocks(ctrl.viewId);
|
|
852
|
+
this.mountSectionsAcrossChain(ctrl.viewId, this.currentLayoutChain);
|
|
853
|
+
commitView(ctrl);
|
|
854
|
+
this.blockManager.startAll();
|
|
855
|
+
this.sectionManager.startAll();
|
|
856
|
+
logger.info(`[ViewManager] prerender → main swap done for "${ctrl.path}" (block content)`);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── Swap: preloadElement (skeleton) → mainElement (real) ──
|
|
861
|
+
// 1. Destroy skeleton khỏi DOM
|
|
862
|
+
if (ctrl.preloadElement && typeof ctrl.preloadElement.destroy === 'function') {
|
|
863
|
+
ctrl.preloadElement.stop?.();
|
|
864
|
+
ctrl.preloadElement.destroy();
|
|
865
|
+
}
|
|
866
|
+
// 2. Mount mainElement vào cùng vị trí (mountRoot)
|
|
867
|
+
if (ctrl.mainElement && mountRoot) {
|
|
868
|
+
ctrl.mainElement.setParentElement(mountRoot);
|
|
869
|
+
ctrl.mainElement.mountTo(mountRoot);
|
|
374
870
|
}
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
871
|
+
// 3. Commit data + start reactivity
|
|
872
|
+
commitView(ctrl);
|
|
873
|
+
ctrl.mainElement?.start?.();
|
|
874
|
+
logger.info(`[ViewManager] prerender → main swap done for "${ctrl.path}"`);
|
|
379
875
|
}).catch((err: any) => {
|
|
380
|
-
|
|
876
|
+
if (!this.isNavigationCurrent(renderGeneration)) return;
|
|
877
|
+
// Fetch hỏng khi skeleton đang hiển thị: không có boundary thì
|
|
878
|
+
// trước giờ chỉ log → skeleton treo vĩnh viễn. Đưa vào boundary
|
|
879
|
+
// để view tự hiện trạng thái lỗi.
|
|
880
|
+
const handled = ctrl.handleError(err, { phase: 'async', path: ctrl.path }).handled;
|
|
881
|
+
if (!handled) logger.error(`Error fetching async data for view "${ctrl.path}":`, err);
|
|
882
|
+
}).finally(() => {
|
|
883
|
+
this.pendingAsyncRenders--;
|
|
884
|
+
// Nội dung thật vừa thay chỗ skeleton. Ai đã trang trí DOM
|
|
885
|
+
// trước đó (tô màu code, dựng mục lục…) phải được báo để
|
|
886
|
+
// làm lại — bản trang trí cũ nằm trên node vừa bị thay.
|
|
887
|
+
if (this.pendingAsyncRenders === 0) {
|
|
888
|
+
(app() as ApplicationInterface).Event?.emit('view:settled');
|
|
889
|
+
}
|
|
381
890
|
});
|
|
382
891
|
|
|
383
892
|
// Return prerender result ngay — mountView sẽ mount skeleton
|
|
@@ -387,17 +896,25 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
387
896
|
// ── Case 3: Có async + không prerender → await fetch rồi render ──
|
|
388
897
|
let asyncData: Record<string, any> = {};
|
|
389
898
|
try {
|
|
390
|
-
const response = await Http.get(fetchUrl);
|
|
391
|
-
asyncData = response
|
|
899
|
+
const response = await Http.get(fetchUrl, this.fetchOptions);
|
|
900
|
+
asyncData = this.extractAsyncData(response);
|
|
392
901
|
} catch (err) {
|
|
393
|
-
|
|
902
|
+
if (!this.isNavigationCurrent(navigationGeneration)) {
|
|
903
|
+
return this.createRenderPageViewCancelled(view);
|
|
904
|
+
}
|
|
905
|
+
const handled = ctrl.handleError(err, { phase: 'async', path: ctrl.path }).handled;
|
|
906
|
+
if (!handled) logger.error(`Error fetching async data for view "${ctrl.path}":`, err);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (!this.isNavigationCurrent(navigationGeneration)) {
|
|
910
|
+
return this.createRenderPageViewCancelled(view);
|
|
394
911
|
}
|
|
395
912
|
|
|
396
913
|
if (!hasData(asyncData)) {
|
|
397
914
|
logger.warn(`View "${ctrl.path}" has async data config but fetch returned no data.`);
|
|
398
915
|
}
|
|
399
916
|
|
|
400
|
-
return this.callViewRenderFactory(view, 'render', asyncData, mountRoot, initMode, cache, renderLevel);
|
|
917
|
+
return this.callViewRenderFactory(view, 'render', asyncData, mountRoot, initMode, cache, renderLevel, navigationGeneration);
|
|
401
918
|
}
|
|
402
919
|
catch (err) {
|
|
403
920
|
logger.error(`Error rendering view ${view.__ctrl__.path}:`, err);
|
|
@@ -416,266 +933,790 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
416
933
|
|
|
417
934
|
// ─── Mount Orchestration ────────────────────────────────────
|
|
418
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Mount view khi navigate — luồng chuẩn (ROUTE_RENDER_FLOW.md):
|
|
938
|
+
* sweep TTL → duplicate guard → pause+cache trang cũ (standalone LẪN
|
|
939
|
+
* page thuộc layout) → thử restore từ PageCache (theo view name + URI,
|
|
940
|
+
* trong TTL, mọi navigation type) → mount mới
|
|
941
|
+
* (render → mount DOM → commitData → start).
|
|
942
|
+
*
|
|
943
|
+
* Lifecycle khi RỜI trang (deactivatePage):
|
|
944
|
+
* - page cacheable → pause (pausing/paused) + detach DOM → PageCache
|
|
945
|
+
* - page cache:false → destroy (stopping/stopped → unmounting/unmounted → destroyed)
|
|
946
|
+
* - layout KHÔNG đổi → không hook nào fire trên layout (giữ nguyên DOM + subscription)
|
|
947
|
+
* - layout đổi/về standalone → destroy layout chain
|
|
948
|
+
*/
|
|
949
|
+
private localeInvalidated = false;
|
|
950
|
+
|
|
951
|
+
invalidateLocale(): void {
|
|
952
|
+
this.cancelNavigation();
|
|
953
|
+
this.localeInvalidated = true;
|
|
954
|
+
this.pageCache.clear();
|
|
955
|
+
}
|
|
956
|
+
|
|
419
957
|
async mountView(name: string, data?: Record<string, any>, route?: ActiveRouteInterface, navigationType: RouterNavigationType = 'push'): Promise<any> {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
424
|
-
|
|
958
|
+
if (this.localeInvalidated) {
|
|
959
|
+
this.unmountAll();
|
|
960
|
+
this.store.clear();
|
|
961
|
+
this.ssrBoot = null;
|
|
962
|
+
this.localeInvalidated = false;
|
|
963
|
+
}
|
|
964
|
+
// Request URI = path + query (KHÔNG hash) — Router cung cấp qua $uri
|
|
965
|
+
const targetUrl = (route as any)?.$uri ?? route?.$urlPath ?? name;
|
|
966
|
+
|
|
967
|
+
// ── Phase 0: TTL sweep + duplicate guard ──
|
|
968
|
+
this.pageCache.sweep();
|
|
969
|
+
if (this.currentPageView
|
|
970
|
+
&& this.currentPageView.__ctrl__.urlPath === targetUrl) {
|
|
971
|
+
return null; // đã đứng đúng trang này
|
|
425
972
|
}
|
|
426
973
|
|
|
974
|
+
// Mọi async work của navigation trước trở thành stale từ thời điểm này.
|
|
975
|
+
const navigationGeneration = ++this.navigationGeneration;
|
|
976
|
+
|
|
977
|
+
// Head tags (title/meta) từ trang trước không được rò rỉ sang trang mới —
|
|
978
|
+
// mountViewSections() của layout/page mới sẽ set lại đúng phần chúng khai báo.
|
|
979
|
+
this.sectionManager.resetPageHead();
|
|
980
|
+
|
|
427
981
|
const oldPageView = this.currentPageView;
|
|
428
982
|
const oldLayoutView = this.currentLayoutView;
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
983
|
+
const oldLayoutChain = [...this.currentLayoutChain];
|
|
984
|
+
// BlockManager hiện đăng ký block theo outlet dùng chung. Với page
|
|
985
|
+
// thuộc Layout, phải detach ownership cũ trước khi render page mới;
|
|
986
|
+
// nếu không block mới sẽ ghi đè registry và cache sai DOM.
|
|
987
|
+
// Standalone không có ràng buộc này nên được giữ active tới commit.
|
|
988
|
+
const oldPageRequiresEarlyDeactivate = oldPageView !== null && oldLayoutChain.length > 0;
|
|
989
|
+
let oldPageDeactivated = false;
|
|
990
|
+
if (oldPageRequiresEarlyDeactivate) {
|
|
991
|
+
this.deactivatePage(oldPageView!, oldLayoutView);
|
|
992
|
+
this.currentPageView = null;
|
|
993
|
+
oldPageDeactivated = true;
|
|
994
|
+
}
|
|
432
995
|
|
|
433
|
-
|
|
434
|
-
|
|
996
|
+
// ── Phase 1: thử restore từ PageCache (key = view name + request URI) ──
|
|
997
|
+
// Trong TTL: mọi navigation type đều restore (bfcache). Hết TTL → mount tươi.
|
|
998
|
+
const cachedEntry = this.pageCache.take(this.cacheKey(name, targetUrl));
|
|
999
|
+
if (cachedEntry) {
|
|
1000
|
+
if (oldPageView && !oldPageDeactivated) {
|
|
1001
|
+
this.deactivatePage(oldPageView, oldLayoutView);
|
|
1002
|
+
oldPageDeactivated = true;
|
|
1003
|
+
}
|
|
1004
|
+
this.currentPageView = null;
|
|
1005
|
+
const restored = this.restoreFromCache(cachedEntry, oldLayoutView, navigationType);
|
|
1006
|
+
if (restored) return restored;
|
|
435
1007
|
}
|
|
436
|
-
oldPageView?.__ctrl__.stop();
|
|
437
1008
|
|
|
438
|
-
// ── Phase 2:
|
|
439
|
-
|
|
1009
|
+
// ── Phase 2: Load + render chain trong khi page cũ vẫn active. ──
|
|
1010
|
+
// Chỉ khi render thành công mới pause/destroy page cũ; fetch/render lỗi
|
|
1011
|
+
// không được làm màn hình hiện tại biến mất.
|
|
1012
|
+
const view = await this.view(name, data ?? {}, false);
|
|
1013
|
+
if (!view) {
|
|
1014
|
+
const message = `Failed to load view "${name}".`;
|
|
1015
|
+
if (oldPageView && oldPageDeactivated) {
|
|
1016
|
+
this.restoreDeactivatedPage(oldPageView!, oldLayoutView);
|
|
1017
|
+
}
|
|
1018
|
+
if (oldPageView) logger.error(message);
|
|
1019
|
+
else this.showError(message);
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
view.__ctrl__.urlPath = targetUrl;
|
|
1023
|
+
|
|
1024
|
+
const renderResult = await this.renderPageView(
|
|
1025
|
+
view, data ?? {}, this.rootElement, InitModes.CREATE, false, 0, navigationGeneration
|
|
1026
|
+
);
|
|
1027
|
+
if (renderResult.type === 'cancelled') {
|
|
1028
|
+
view.__ctrl__.destroy();
|
|
1029
|
+
if (oldPageView && oldPageDeactivated) {
|
|
1030
|
+
this.restoreDeactivatedPage(oldPageView!, oldLayoutView);
|
|
1031
|
+
}
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
440
1034
|
if (renderResult.type === 'error') {
|
|
441
|
-
|
|
1035
|
+
view.__ctrl__.destroy();
|
|
1036
|
+
if (oldPageView && oldPageDeactivated) {
|
|
1037
|
+
this.restoreDeactivatedPage(oldPageView!, oldLayoutView);
|
|
1038
|
+
}
|
|
1039
|
+
if (oldPageView) logger.error(renderResult.message);
|
|
1040
|
+
else this.showError(renderResult.message);
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ── Phase 3: commit transition ──
|
|
1045
|
+
// Page/Layout mới đã resolve xong; bây giờ mới rời chain cũ và mount chain mới.
|
|
1046
|
+
if (oldPageView && !oldPageDeactivated) {
|
|
1047
|
+
this.deactivatePage(oldPageView, oldLayoutView);
|
|
1048
|
+
}
|
|
1049
|
+
this.currentPageView = null;
|
|
1050
|
+
// Phase mount nằm NGOÀI try/catch của renderPageView: lỗi phát sinh khi
|
|
1051
|
+
// gắn tree vào DOM (vd @include con throw) trước đây thoát hẳn ra ngoài
|
|
1052
|
+
// → unhandled rejection + trang mount dở. Error boundary đã xử lý phần
|
|
1053
|
+
// lớn (Component/Reactive); đây là lưới cuối khi không boundary nào nhận.
|
|
1054
|
+
try {
|
|
1055
|
+
this.activateRenderedChain(renderResult, InitModes.CREATE, oldLayoutChain);
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
logger.error(`Error mounting view "${name}":`, err);
|
|
1058
|
+
this.showError(`Error mounting view "${name}".`, err instanceof Error ? err.message : err);
|
|
442
1059
|
return null;
|
|
443
1060
|
}
|
|
444
1061
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const isSameLayout = hasSuperView
|
|
454
|
-
&& oldLayoutView !== null
|
|
455
|
-
&& finalView === oldLayoutView;
|
|
456
|
-
|
|
457
|
-
// ── Duplicate navigation guard ──
|
|
458
|
-
if (oldPageView === pageView
|
|
459
|
-
&& view.__ctrl__.urlPath === oldPageView?.__ctrl__?.urlPath) {
|
|
460
|
-
return renderResult;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// ══════════════════════════════════════════════════════════
|
|
464
|
-
// Case 1: Có superView + cùng layout instance (reuse layout)
|
|
465
|
-
// ══════════════════════════════════════════════════════════
|
|
466
|
-
if (hasSuperView && isSameLayout) {
|
|
467
|
-
// Cắt origin chain cũ → gắn page mới vào layout
|
|
468
|
-
oldLayoutView!.__ctrl__.ejectOriginChain();
|
|
469
|
-
pageView.__ctrl__.setChainFromOrigin();
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
// Mount block content mới vào layout outlets
|
|
473
|
-
this.blockManager.mountAll();
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
// ══════════════════════════════════════════════════════════
|
|
477
|
-
// Case 2: Có superView + layout khác (hoặc first mount có layout)
|
|
478
|
-
// ══════════════════════════════════════════════════════════
|
|
479
|
-
else if (hasSuperView) {
|
|
480
|
-
// Cleanup toàn bộ old views
|
|
481
|
-
oldLayoutView?.__ctrl__.ejectOriginChain();
|
|
482
|
-
this.stopPageView(oldPageView);
|
|
483
|
-
this.stopLayoutView(oldLayoutView);
|
|
484
|
-
this.blockManager.clearAllOutlets();
|
|
485
|
-
this.unmountLayoutDOM(oldLayoutView);
|
|
486
|
-
|
|
487
|
-
// Gắn origin chain cho layout mới
|
|
488
|
-
pageView.__ctrl__.setChainFromOrigin();
|
|
489
|
-
|
|
490
|
-
// Build DOM từ layout wrapper vào container
|
|
491
|
-
this.buildViewDOM(finalView);
|
|
492
|
-
|
|
493
|
-
// Mount block content vào layout outlets
|
|
494
|
-
this.blockManager.mountAll();
|
|
495
|
-
|
|
496
|
-
// Commit & Start toàn bộ chain
|
|
497
|
-
this.commitViewChain(pageView, finalView, true);
|
|
498
|
-
this.startViewChain(pageView, finalView, true);
|
|
499
|
-
}
|
|
500
|
-
// ══════════════════════════════════════════════════════════
|
|
501
|
-
// Case 3: Không có superView (standalone view)
|
|
502
|
-
// ══════════════════════════════════════════════════════════
|
|
503
|
-
else {
|
|
504
|
-
// Cleanup toàn bộ old views
|
|
505
|
-
oldLayoutView?.__ctrl__.ejectOriginChain();
|
|
506
|
-
this.stopPageView(oldPageView);
|
|
507
|
-
this.stopLayoutView(oldLayoutView);
|
|
508
|
-
this.blockManager.clearAllOutlets();
|
|
509
|
-
this.unmountLayoutDOM(oldLayoutView);
|
|
510
|
-
|
|
511
|
-
// Build DOM trực tiếp vào container
|
|
512
|
-
this.buildViewDOM(pageView);
|
|
513
|
-
|
|
514
|
-
// Commit & Start
|
|
515
|
-
this.commitViewChain(pageView, pageView, false);
|
|
516
|
-
this.startViewChain(pageView, pageView, false);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// ── Update state ──
|
|
1062
|
+
return renderResult;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Commit the successfully mounted/hydrated chain as the only active route state. */
|
|
1066
|
+
private commitActiveChain(
|
|
1067
|
+
pageView: ViewInterface,
|
|
1068
|
+
layoutChain: ViewInterface[],
|
|
1069
|
+
): void {
|
|
520
1070
|
this.currentPageView = pageView;
|
|
521
|
-
this.
|
|
522
|
-
this.
|
|
523
|
-
this.
|
|
524
|
-
this.
|
|
1071
|
+
this.currentLayoutChain = [...layoutChain];
|
|
1072
|
+
this.currentLayoutView = layoutChain[0] ?? null;
|
|
1073
|
+
this.currentLayoutPath = this.currentLayoutView?.__ctrl__.path ?? null;
|
|
1074
|
+
this.currentViewType = layoutChain.length > 0 ? 'layout' : 'view';
|
|
1075
|
+
this.viewStack = [...layoutChain, pageView];
|
|
525
1076
|
this.renderCount++;
|
|
1077
|
+
}
|
|
526
1078
|
|
|
527
|
-
|
|
1079
|
+
/**
|
|
1080
|
+
* Common post-render transaction. Rendering decides the Page/Layout chain;
|
|
1081
|
+
* this step applies the DOM strategy, activates it, then publishes one
|
|
1082
|
+
* coherent active-chain state.
|
|
1083
|
+
*/
|
|
1084
|
+
private activateRenderedChain(
|
|
1085
|
+
renderResult: RenderPageViewSuccess,
|
|
1086
|
+
initMode: InitMode,
|
|
1087
|
+
oldLayoutChain: ViewInterface[] = [],
|
|
1088
|
+
): void {
|
|
1089
|
+
const pageView = renderResult.view;
|
|
1090
|
+
const layoutChain = renderResult.chain.slice(1).reverse();
|
|
1091
|
+
|
|
1092
|
+
if (initMode === InitModes.HYDRATE) {
|
|
1093
|
+
this.activateHydratedChain(pageView, layoutChain);
|
|
1094
|
+
} else {
|
|
1095
|
+
this.activateCreatedChain(pageView, layoutChain, oldLayoutChain);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
this.commitActiveChain(pageView, layoutChain);
|
|
528
1099
|
}
|
|
529
1100
|
|
|
530
|
-
|
|
1101
|
+
/** CSR strategy: insert new DOM, while preserving/reusing a compatible Layout. */
|
|
1102
|
+
private activateCreatedChain(
|
|
1103
|
+
pageView: ViewInterface,
|
|
1104
|
+
layoutChain: ViewInterface[],
|
|
1105
|
+
oldLayoutChain: ViewInterface[],
|
|
1106
|
+
): void {
|
|
1107
|
+
const pageCtrl = pageView.__ctrl__;
|
|
1108
|
+
|
|
1109
|
+
if (layoutChain.length === 0) {
|
|
1110
|
+
if (oldLayoutChain.length > 0) {
|
|
1111
|
+
this.deactivateLayoutChain(oldLayoutChain);
|
|
1112
|
+
this.currentLayoutView = null;
|
|
1113
|
+
this.currentLayoutChain = [];
|
|
1114
|
+
this.currentLayoutPath = null;
|
|
1115
|
+
}
|
|
1116
|
+
pageCtrl.mount(this.rootElement!);
|
|
1117
|
+
commitView(pageCtrl);
|
|
1118
|
+
activateView(pageCtrl);
|
|
1119
|
+
this.sectionManager.mountViewSections(pageCtrl.viewId);
|
|
1120
|
+
this.sectionManager.startAll();
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
let common = 0;
|
|
1125
|
+
while (common < oldLayoutChain.length
|
|
1126
|
+
&& common < layoutChain.length
|
|
1127
|
+
&& oldLayoutChain[common] === layoutChain[common]) {
|
|
1128
|
+
common++;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (common === 0 && oldLayoutChain.length > 0) {
|
|
1132
|
+
this.deactivateLayoutChain(oldLayoutChain);
|
|
1133
|
+
} else {
|
|
1134
|
+
// Root layout vẫn dùng chung; chỉ dọn phần chain không còn reuse.
|
|
1135
|
+
for (let i = oldLayoutChain.length - 1; i >= common; i--) {
|
|
1136
|
+
this.destroyLayoutView(oldLayoutChain[i]);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const resumed = common === 0 && this.resumeLayoutChainFromCache(layoutChain);
|
|
1141
|
+
const newLayouts: ViewInterface[] = [];
|
|
1142
|
+
|
|
1143
|
+
if (!resumed) {
|
|
1144
|
+
let start = common;
|
|
1145
|
+
if (common === 0) {
|
|
1146
|
+
layoutChain[0].__ctrl__.mount(this.rootElement!);
|
|
1147
|
+
newLayouts.push(layoutChain[0]);
|
|
1148
|
+
start = 1;
|
|
1149
|
+
}
|
|
1150
|
+
for (let i = start; i < layoutChain.length; i++) {
|
|
1151
|
+
const layout = layoutChain[i];
|
|
1152
|
+
this.blockManager.mountViewBlocks(layout.__ctrl__.viewId);
|
|
1153
|
+
this.sectionManager.mountViewSections(layout.__ctrl__.viewId);
|
|
1154
|
+
layout.__ctrl__.mount();
|
|
1155
|
+
newLayouts.push(layout);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
this.blockManager.mountViewBlocks(pageCtrl.viewId);
|
|
1160
|
+
this.mountSectionsAcrossChain(pageCtrl.viewId, layoutChain);
|
|
1161
|
+
pageCtrl.mount();
|
|
1162
|
+
for (const layout of newLayouts) commitView(layout.__ctrl__);
|
|
1163
|
+
commitView(pageCtrl);
|
|
1164
|
+
|
|
1165
|
+
for (const layout of newLayouts) activateView(layout.__ctrl__);
|
|
1166
|
+
this.blockManager.startAll();
|
|
1167
|
+
this.sectionManager.startAll();
|
|
1168
|
+
activateView(pageCtrl);
|
|
1169
|
+
}
|
|
531
1170
|
|
|
532
1171
|
/**
|
|
533
|
-
*
|
|
534
|
-
*
|
|
1172
|
+
* Đưa `@section` của page tới `@yield` của layout.
|
|
1173
|
+
*
|
|
1174
|
+
* `mountViewSections` lọc yield theo `yieldEl.ctx.viewId`, mà YIELD THUỘC
|
|
1175
|
+
* LAYOUT khai báo nó chứ không thuộc page — nên gọi riêng với viewId của
|
|
1176
|
+
* page thì không yield nào khớp. Chỉ quét các layout MỚI cũng không đủ:
|
|
1177
|
+
* điều hướng giữa hai trang dùng chung một layout thì `common` phủ hết
|
|
1178
|
+
* chuỗi, vòng lặp mount layout không chạy lần nào, và section của trang mới
|
|
1179
|
+
* không bao giờ tới nơi.
|
|
1180
|
+
*
|
|
1181
|
+
* Quét cả chuỗi là an toàn: mountViewSections chỉ áp lại `activeSections`
|
|
1182
|
+
* hiện hành vào từng yield, gọi thừa không đổi kết quả.
|
|
535
1183
|
*/
|
|
536
|
-
private
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1184
|
+
private mountSectionsAcrossChain(pageViewId: string, layoutChain: ViewInterface[]): void {
|
|
1185
|
+
this.sectionManager.mountViewSections(pageViewId);
|
|
1186
|
+
for (const layout of layoutChain) {
|
|
1187
|
+
const layoutId = layout?.__ctrl__?.viewId;
|
|
1188
|
+
if (layoutId && layoutId !== pageViewId) {
|
|
1189
|
+
this.sectionManager.mountViewSections(layoutId);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Hydration strategy: claim Blade DOM without insert/clear mutations. */
|
|
1195
|
+
private activateHydratedChain(
|
|
1196
|
+
pageView: ViewInterface,
|
|
1197
|
+
layoutChain: ViewInterface[],
|
|
1198
|
+
): void {
|
|
1199
|
+
const pageCtrl = pageView.__ctrl__;
|
|
1200
|
+
|
|
1201
|
+
if (layoutChain.length === 0) {
|
|
1202
|
+
commitView(pageCtrl, true);
|
|
1203
|
+
claimHydratedView(pageCtrl, this.rootElement!);
|
|
1204
|
+
pageCtrl.mount();
|
|
1205
|
+
pageCtrl.initMode = InitModes.CREATE;
|
|
1206
|
+
activateView(pageCtrl);
|
|
1207
|
+
this.sectionManager.hydrateViewSections(pageCtrl.viewId);
|
|
1208
|
+
this.sectionManager.startAll();
|
|
540
1209
|
return;
|
|
541
1210
|
}
|
|
542
|
-
|
|
543
|
-
|
|
1211
|
+
|
|
1212
|
+
for (const layout of layoutChain) commitView(layout.__ctrl__, true);
|
|
1213
|
+
commitView(pageCtrl, true);
|
|
1214
|
+
const rootLayoutCtrl = layoutChain[0].__ctrl__;
|
|
1215
|
+
claimHydratedView(rootLayoutCtrl, this.rootElement!);
|
|
1216
|
+
rootLayoutCtrl.mount();
|
|
1217
|
+
for (let i = 1; i < layoutChain.length; i++) {
|
|
1218
|
+
const ctrl = layoutChain[i].__ctrl__;
|
|
1219
|
+
this.blockManager.hydrateViewBlocks(ctrl.viewId);
|
|
1220
|
+
this.sectionManager.hydrateViewSections(ctrl.viewId);
|
|
1221
|
+
ctrl.mount();
|
|
1222
|
+
}
|
|
1223
|
+
this.blockManager.hydrateViewBlocks(pageCtrl.viewId);
|
|
1224
|
+
this.sectionManager.hydrateViewSections(pageCtrl.viewId);
|
|
1225
|
+
pageCtrl.mount();
|
|
1226
|
+
for (const layout of layoutChain) layout.__ctrl__.initMode = InitModes.CREATE;
|
|
1227
|
+
pageCtrl.initMode = InitModes.CREATE;
|
|
1228
|
+
|
|
1229
|
+
for (const layout of layoutChain) activateView(layout.__ctrl__);
|
|
1230
|
+
this.blockManager.startAll();
|
|
1231
|
+
this.sectionManager.startAll();
|
|
1232
|
+
activateView(pageCtrl);
|
|
544
1233
|
}
|
|
545
1234
|
|
|
546
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* Destroy layout cũ (đổi layout hoặc về standalone).
|
|
1237
|
+
* Page cũ đã rời ở Phase 1 (deactivatePage) — không destroy tại đây.
|
|
1238
|
+
*/
|
|
1239
|
+
private destroyLayoutView(layoutView: ViewInterface): void {
|
|
1240
|
+
const layoutPath = layoutView.__ctrl__.path;
|
|
1241
|
+
layoutView.__ctrl__.destroy();
|
|
1242
|
+
// Layout cached theo path trong store (extendView dùng cache=true) —
|
|
1243
|
+
// instance đã destroy không được phép trả về từ cache nữa
|
|
1244
|
+
if (layoutPath && this.store.has(layoutPath)) {
|
|
1245
|
+
this.store.remove?.(layoutPath);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
547
1248
|
|
|
548
|
-
private
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
this.stopBlockContent();
|
|
552
|
-
// Fire onDeactivated hook
|
|
553
|
-
if (typeof pageView.onDeactivated === 'function') {
|
|
554
|
-
try { pageView.onDeactivated(); }
|
|
555
|
-
catch (e) { logger.error(`[ViewManager] onDeactivated error:`, e); }
|
|
1249
|
+
private destroyLayoutChain(layoutChain: ViewInterface[]): void {
|
|
1250
|
+
for (let i = layoutChain.length - 1; i >= 0; i--) {
|
|
1251
|
+
this.destroyLayoutView(layoutChain[i]);
|
|
556
1252
|
}
|
|
557
1253
|
}
|
|
558
1254
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1255
|
+
// ─── PageCache integration ──────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
/** bfcache-style cache cho trang đã ghé (ROUTE_RENDER_FLOW.md §8) */
|
|
1258
|
+
public pageCache: PageCacheService = new PageCacheService();
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Cache key = `${viewName}::${requestUri}` — URI gồm path + query,
|
|
1262
|
+
* KHÔNG gồm hash (strip defensive tại đây).
|
|
1263
|
+
*/
|
|
1264
|
+
private cacheKey(viewName: string, uri: string): string {
|
|
1265
|
+
return `${viewName}::${(uri ?? '').split('#')[0]}`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Cache key cho layout — namespace riêng, không đụng key page (name::uri) */
|
|
1269
|
+
private layoutCacheKey(layoutPath: string): string {
|
|
1270
|
+
return `__layout__::${layoutPath}`;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private layoutChainIdentity(layoutChain: ViewInterface[]): string {
|
|
1274
|
+
return layoutChain.map(view => view.__ctrl__.path).join('>');
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Rời một layout (đổi layout / về standalone): pause + detach toàn vùng DOM
|
|
1279
|
+
* → PageCache (key `__layout__::{path}`). KHÁC destroy ở 2 điểm:
|
|
1280
|
+
* 1. Instance GIỮ NGUYÊN trong store — extendView của page sau trả lại
|
|
1281
|
+
* đúng instance này (đang paused) → resumeLayoutFromCache nhận ra.
|
|
1282
|
+
* 2. Outlets gỡ khỏi BlockManager registry (không destroy) — tránh
|
|
1283
|
+
* mountAll đụng outlet trùng tên của layout đang nằm trong cache.
|
|
1284
|
+
* Layout khai cache:false (hoặc ttl 0) → destroy như cũ.
|
|
1285
|
+
*/
|
|
1286
|
+
private deactivateLayout(layoutView: ViewInterface): void {
|
|
1287
|
+
this.deactivateLayoutChain([layoutView]);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private deactivateLayoutChain(layoutChain: ViewInterface[]): void {
|
|
1291
|
+
const rootCtrl = layoutChain[0]?.__ctrl__;
|
|
1292
|
+
if (!rootCtrl) return;
|
|
1293
|
+
const cacheConfig = rootCtrl.getConfig('cache');
|
|
1294
|
+
const ttl = typeof cacheConfig === 'object' && cacheConfig?.ttl != null ? cacheConfig.ttl : undefined;
|
|
1295
|
+
const wrapper = rootCtrl.mainElement;
|
|
1296
|
+
|
|
1297
|
+
if (!wrapper || cacheConfig === false || ttl === 0) {
|
|
1298
|
+
this.destroyLayoutChain(layoutChain);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
for (let i = layoutChain.length - 1; i >= 0; i--) {
|
|
1303
|
+
layoutChain[i].__ctrl__.pause();
|
|
564
1304
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
catch (e) { logger.error(`[ViewManager] layout onDeactivated error:`, e); }
|
|
1305
|
+
for (const layout of layoutChain) {
|
|
1306
|
+
this.blockManager.detachOutletsOfView(layout.__ctrl__.viewId);
|
|
568
1307
|
}
|
|
1308
|
+
const fragment = detachWrapperDOM(wrapper);
|
|
1309
|
+
this.pageCache.set(this.layoutCacheKey(this.layoutChainIdentity(layoutChain)), {
|
|
1310
|
+
views: [...layoutChain],
|
|
1311
|
+
fragment,
|
|
1312
|
+
layoutPath: null,
|
|
1313
|
+
scroll: { x: 0, y: 0 },
|
|
1314
|
+
ttl,
|
|
1315
|
+
});
|
|
569
1316
|
}
|
|
570
1317
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
1318
|
+
/**
|
|
1319
|
+
* Layout lấy từ store đang PAUSED (đã vào cache trước đó) → reattach
|
|
1320
|
+
* fragment + resume thay vì mount lại. Trả false nếu layout không paused
|
|
1321
|
+
* (layout mới tạo → caller mount bình thường).
|
|
1322
|
+
*/
|
|
1323
|
+
private resumeLayoutChainFromCache(layoutChain: ViewInterface[]): boolean {
|
|
1324
|
+
if (layoutChain.length === 0
|
|
1325
|
+
|| layoutChain.some(layout => layout.__ctrl__.lifecycleState !== 'paused')) return false;
|
|
1326
|
+
|
|
1327
|
+
const entry = this.pageCache.take(this.layoutCacheKey(this.layoutChainIdentity(layoutChain)));
|
|
1328
|
+
if (entry) {
|
|
1329
|
+
this.rootElement!.getElement().appendChild(entry.fragment);
|
|
1330
|
+
for (const layout of layoutChain) layout.__ctrl__.resume();
|
|
1331
|
+
} else {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
for (const layout of layoutChain) this.reregisterLayoutOutlets(layout.__ctrl__);
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Đăng ký lại outlets của layout vừa resume vào BlockManager registry */
|
|
1339
|
+
private reregisterLayoutOutlets(layoutCtrl: ViewControllerInterface): void {
|
|
1340
|
+
const elements: Map<string, any> | undefined = (layoutCtrl as any).elements;
|
|
1341
|
+
if (!elements) return;
|
|
1342
|
+
for (const [id, el] of elements) {
|
|
1343
|
+
if (el instanceof BlockOutlet && !(el as any).__destroyed__) {
|
|
1344
|
+
this.blockManager.addOutlet(id, el);
|
|
580
1345
|
}
|
|
581
1346
|
}
|
|
582
1347
|
}
|
|
583
1348
|
|
|
584
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Navigate rời một page: pause + detach DOM → PageCache.
|
|
1351
|
+
* - Standalone: detach toàn vùng wrapper (markers + content).
|
|
1352
|
+
* - Page thuộc layout: detach block content THEO OUTLET — layout giữ nguyên,
|
|
1353
|
+
* không hook nào fire trên layout.
|
|
1354
|
+
* View khai cache:false (hoặc ttl 0) → destroy luôn.
|
|
1355
|
+
*/
|
|
1356
|
+
private deactivatePage(
|
|
1357
|
+
pageView: ViewInterface,
|
|
1358
|
+
layoutView: ViewInterface | null,
|
|
1359
|
+
layoutChain: ViewInterface[] = this.currentLayoutChain,
|
|
1360
|
+
): void {
|
|
1361
|
+
const ctrl = pageView.__ctrl__;
|
|
1362
|
+
const urlPath = ctrl.urlPath;
|
|
1363
|
+
const cacheConfig = ctrl.getConfig('cache');
|
|
1364
|
+
const ttl = typeof cacheConfig === 'object' && cacheConfig?.ttl != null ? cacheConfig.ttl : undefined;
|
|
1365
|
+
|
|
1366
|
+
if (!urlPath || cacheConfig === false || ttl === 0) {
|
|
1367
|
+
ctrl.destroy();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const scroll = {
|
|
1372
|
+
x: typeof window !== 'undefined' ? window.scrollX ?? 0 : 0,
|
|
1373
|
+
y: typeof window !== 'undefined' ? window.scrollY ?? 0 : 0,
|
|
1374
|
+
};
|
|
585
1375
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
this.startBlockContent();
|
|
592
|
-
// 3. Fire page onMounted
|
|
593
|
-
if (typeof pageView.onMounted === 'function') {
|
|
594
|
-
try { pageView.onMounted(); }
|
|
595
|
-
catch (e) { logger.error(`[ViewManager] page onMounted error:`, e); }
|
|
1376
|
+
if (!layoutView) {
|
|
1377
|
+
const wrapper = ctrl.mainElement;
|
|
1378
|
+
if (!wrapper) {
|
|
1379
|
+
ctrl.destroy();
|
|
1380
|
+
return;
|
|
596
1381
|
}
|
|
1382
|
+
// pause TRƯỚC detach: flush pending → DOM snapshot nhất quán rồi mới rời
|
|
1383
|
+
ctrl.pause();
|
|
1384
|
+
const fragment = detachWrapperDOM(wrapper);
|
|
1385
|
+
this.pageCache.set(this.cacheKey(ctrl.path, urlPath), {
|
|
1386
|
+
views: [pageView], fragment, layoutPath: null, scroll, ttl,
|
|
1387
|
+
});
|
|
597
1388
|
} else {
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
1389
|
+
ctrl.pause();
|
|
1390
|
+
const outletContents = this.blockManager.detachPageContent(ctrl.viewId);
|
|
1391
|
+
this.pageCache.set(this.cacheKey(ctrl.path, urlPath), {
|
|
1392
|
+
views: [pageView],
|
|
1393
|
+
outletContents,
|
|
1394
|
+
layoutPath: this.layoutChainIdentity(layoutChain.length > 0 ? layoutChain : [layoutView]),
|
|
1395
|
+
scroll,
|
|
1396
|
+
ttl,
|
|
1397
|
+
});
|
|
607
1398
|
}
|
|
608
1399
|
}
|
|
609
1400
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1401
|
+
/** Roll back an early Layout-page detach when prepare/render did not commit. */
|
|
1402
|
+
private restoreDeactivatedPage(
|
|
1403
|
+
pageView: ViewInterface,
|
|
1404
|
+
layoutView: ViewInterface | null,
|
|
1405
|
+
): boolean {
|
|
1406
|
+
const ctrl = pageView.__ctrl__;
|
|
1407
|
+
if (!ctrl.urlPath) return false;
|
|
1408
|
+
const entry = this.pageCache.take(this.cacheKey(ctrl.path, ctrl.urlPath));
|
|
1409
|
+
if (!entry) return false;
|
|
1410
|
+
return this.restoreFromCache(entry, layoutView, 'pop') !== null;
|
|
619
1411
|
}
|
|
620
1412
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1413
|
+
/**
|
|
1414
|
+
* Cache hit: gắn lại DOM + resume — không render, không gọi API.
|
|
1415
|
+
* - Entry standalone: layout cũ (nếu còn) pause+cache → gắn fragment vào container.
|
|
1416
|
+
* - Entry layout-page: layout đang mount trùng layoutPath → restore thẳng
|
|
1417
|
+
* vào outlets. Không trùng → thử resurrect layout từ layout cache
|
|
1418
|
+
* (bfcache đầy đủ: restore CẢ layout LẪN page trong một lần). Layout
|
|
1419
|
+
* cũng không có trong cache → putBack entry (còn TTL thì lần điều hướng
|
|
1420
|
+
* sau restore được), trả null để caller mount tươi.
|
|
1421
|
+
* Scroll: pop → khôi phục vị trí cũ; push → về đầu trang.
|
|
1422
|
+
*/
|
|
1423
|
+
private restoreFromCache(
|
|
1424
|
+
entry: PageCacheEntry,
|
|
1425
|
+
oldLayoutView: ViewInterface | null,
|
|
1426
|
+
navigationType: RouterNavigationType
|
|
1427
|
+
): any {
|
|
1428
|
+
const pageView = entry.views[entry.views.length - 1];
|
|
1429
|
+
|
|
1430
|
+
if (entry.layoutPath) {
|
|
1431
|
+
// ── Page thuộc layout ────────────────────────────────────────
|
|
1432
|
+
// 1. Layout đang mount trùng path? → dùng luôn
|
|
1433
|
+
let layoutChain = this.layoutChainIdentity(this.currentLayoutChain) === entry.layoutPath
|
|
1434
|
+
? [...this.currentLayoutChain]
|
|
1435
|
+
: [];
|
|
1436
|
+
let layoutView: ViewInterface | null = layoutChain[0] ?? null;
|
|
1437
|
+
|
|
1438
|
+
// 2. Không trùng → thử resurrect layout từ layout cache
|
|
1439
|
+
if (!layoutView && entry.outletContents) {
|
|
1440
|
+
const layoutEntry = this.pageCache.take(this.layoutCacheKey(entry.layoutPath));
|
|
1441
|
+
if (layoutEntry) {
|
|
1442
|
+
if (this.currentLayoutChain.length > 0) {
|
|
1443
|
+
this.deactivateLayoutChain(this.currentLayoutChain);
|
|
627
1444
|
}
|
|
1445
|
+
layoutChain = [...layoutEntry.views];
|
|
1446
|
+
const lv = layoutChain[0];
|
|
1447
|
+
this.rootElement!.getElement().appendChild(layoutEntry.fragment);
|
|
1448
|
+
for (const layout of layoutChain) layout.__ctrl__.resume();
|
|
1449
|
+
for (const layout of layoutChain) this.reregisterLayoutOutlets(layout.__ctrl__);
|
|
1450
|
+
layoutView = lv;
|
|
628
1451
|
}
|
|
629
1452
|
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
1453
|
|
|
633
|
-
|
|
1454
|
+
// 3. Không có layout để restore vào → trả entry lại cache, mount tươi
|
|
1455
|
+
if (!layoutView || !entry.outletContents) {
|
|
1456
|
+
this.pageCache.putBack(entry);
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
634
1459
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1460
|
+
this.blockManager.restorePageContent(pageView.__ctrl__.viewId, entry.outletContents);
|
|
1461
|
+
for (const v of entry.views) {
|
|
1462
|
+
v.__ctrl__.resume();
|
|
1463
|
+
}
|
|
1464
|
+
for (const v of entry.views) {
|
|
1465
|
+
this.sectionManager.mountViewSections(v.__ctrl__.viewId);
|
|
1466
|
+
}
|
|
1467
|
+
this.currentPageView = pageView;
|
|
1468
|
+
this.currentLayoutView = layoutView;
|
|
1469
|
+
this.currentLayoutChain = layoutChain;
|
|
1470
|
+
this.currentLayoutPath = layoutView.__ctrl__.path;
|
|
1471
|
+
this.currentViewType = 'layout';
|
|
1472
|
+
this.viewStack = [...layoutChain, pageView];
|
|
639
1473
|
} else {
|
|
640
|
-
|
|
1474
|
+
// ── Standalone ───────────────────────────────────────────────
|
|
1475
|
+
if (this.currentLayoutChain.length > 0) {
|
|
1476
|
+
this.deactivateLayoutChain(this.currentLayoutChain);
|
|
1477
|
+
this.currentLayoutView = null;
|
|
1478
|
+
this.currentLayoutChain = [];
|
|
1479
|
+
this.currentLayoutPath = null;
|
|
1480
|
+
}
|
|
1481
|
+
const container = this.rootElement!.getElement();
|
|
1482
|
+
container.appendChild(entry.fragment);
|
|
1483
|
+
for (const v of entry.views) {
|
|
1484
|
+
v.__ctrl__.resume();
|
|
1485
|
+
}
|
|
1486
|
+
for (const v of entry.views) {
|
|
1487
|
+
this.sectionManager.mountViewSections(v.__ctrl__.viewId);
|
|
1488
|
+
}
|
|
1489
|
+
this.currentPageView = pageView;
|
|
1490
|
+
this.currentLayoutView = null;
|
|
1491
|
+
this.currentLayoutChain = [];
|
|
1492
|
+
this.currentLayoutPath = null;
|
|
1493
|
+
this.currentViewType = 'view';
|
|
1494
|
+
this.viewStack = [...entry.views];
|
|
641
1495
|
}
|
|
642
|
-
|
|
1496
|
+
this.renderCount++;
|
|
643
1497
|
|
|
644
|
-
|
|
1498
|
+
try {
|
|
1499
|
+
if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
|
|
1500
|
+
if (navigationType === 'pop') {
|
|
1501
|
+
window.scrollTo(entry.scroll.x, entry.scroll.y);
|
|
1502
|
+
} else {
|
|
1503
|
+
window.scrollTo(0, 0);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
} catch { /* jsdom không hỗ trợ — bỏ qua */ }
|
|
645
1507
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
1508
|
+
return {
|
|
1509
|
+
type: 'restored',
|
|
1510
|
+
view: pageView,
|
|
1511
|
+
finalView: this.currentLayoutView ?? pageView,
|
|
1512
|
+
superView: this.currentLayoutView,
|
|
1513
|
+
};
|
|
652
1514
|
}
|
|
653
1515
|
|
|
1516
|
+
// ─── Unmount ────────────────────────────────────────────────
|
|
1517
|
+
|
|
654
1518
|
unmountAll(): void {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
this.blockManager.
|
|
658
|
-
this.
|
|
1519
|
+
// Stop block content (unsubscribe) trước, rồi destroy chain trong → ngoài.
|
|
1520
|
+
// ctrl.destroy() tự fire đủ hook: stopping/stopped → unmounting/unmounted → destroyed.
|
|
1521
|
+
this.blockManager.stopAll();
|
|
1522
|
+
this.sectionManager.stopAll();
|
|
659
1523
|
|
|
660
1524
|
if (this.currentPageView) {
|
|
661
1525
|
this.currentPageView.__ctrl__.destroy();
|
|
662
1526
|
}
|
|
663
|
-
|
|
664
|
-
this.
|
|
1527
|
+
for (let i = this.currentLayoutChain.length - 1; i >= 0; i--) {
|
|
1528
|
+
this.currentLayoutChain[i].__ctrl__.destroy();
|
|
665
1529
|
}
|
|
666
1530
|
|
|
1531
|
+
this.blockManager.clearAllOutlets();
|
|
1532
|
+
this.blockManager.destroy();
|
|
1533
|
+
this.sectionManager.destroy();
|
|
1534
|
+
|
|
667
1535
|
this.currentPageView = null;
|
|
668
1536
|
this.currentLayoutView = null;
|
|
1537
|
+
this.currentLayoutChain = [];
|
|
669
1538
|
this.currentLayoutPath = null;
|
|
670
1539
|
this.currentViewType = null;
|
|
671
|
-
this.activeViews.clear();
|
|
672
1540
|
this.viewStack = [];
|
|
673
1541
|
}
|
|
674
1542
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1543
|
+
/**
|
|
1544
|
+
* Apply an atomic context update received from a JSON response before the
|
|
1545
|
+
* Router retries navigation with the newly materialized route table.
|
|
1546
|
+
*/
|
|
1547
|
+
requiresReloadForViewContext(state: Record<string, any>): boolean {
|
|
1548
|
+
const revision = typeof state?.revision === 'string' ? state.revision : null;
|
|
1549
|
+
if (!revision || revision === this.contextRevision) return false;
|
|
1550
|
+
|
|
1551
|
+
const nextViews = typeof state?.views === 'string' && state.views !== ''
|
|
1552
|
+
? state.views
|
|
1553
|
+
: this.contextViews;
|
|
1554
|
+
|
|
1555
|
+
// The current registry was built for one namespace. Changing only its
|
|
1556
|
+
// string prefix cannot materialize the new theme factories/CSS and can
|
|
1557
|
+
// mix new Blade names with old JavaScript. Until a staged registry swap
|
|
1558
|
+
// exists, a document load is the only coherent transition.
|
|
1559
|
+
return nextViews !== this.contextViews;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
applyViewContext(state: Record<string, any>): boolean {
|
|
1563
|
+
if (this.requiresReloadForViewContext(state)) {
|
|
1564
|
+
// Router owns the document transition. A direct caller must not
|
|
1565
|
+
// mutate revision/systemData while the matching registry is absent.
|
|
1566
|
+
return false;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
const revision = typeof state?.revision === 'string' ? state.revision : null;
|
|
1570
|
+
if (!revision || revision === this.contextRevision) return false;
|
|
1571
|
+
|
|
1572
|
+
this.cancelNavigation();
|
|
1573
|
+
this.contextRevision = revision;
|
|
1574
|
+
this.contextViews = typeof state.views === 'string' ? state.views : this.contextViews;
|
|
1575
|
+
if (state.systemData && typeof state.systemData === 'object') {
|
|
1576
|
+
this.systemData = { ...this.systemData, ...state.systemData };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Cached pages/layout factories belong to the previous namespace.
|
|
1580
|
+
this.pageCache.clear();
|
|
1581
|
+
this.store.clear();
|
|
1582
|
+
this.ssrBoot = null;
|
|
1583
|
+
|
|
1584
|
+
if (typeof window !== 'undefined') {
|
|
1585
|
+
const appConfigs = (window as any).APP_CONFIGS ??= {};
|
|
1586
|
+
appConfigs.view ??= {};
|
|
1587
|
+
appConfigs.view.revision = revision;
|
|
1588
|
+
appConfigs.view.contextViews = this.contextViews;
|
|
1589
|
+
appConfigs.view.systemData = { ...this.systemData };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
return true;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
getContextRevision(): string | null {
|
|
1596
|
+
return this.contextRevision;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private extractAsyncData(response: any): Record<string, any> {
|
|
1600
|
+
const payload = response?.data ?? {};
|
|
1601
|
+
if (payload && typeof payload === 'object'
|
|
1602
|
+
&& Object.prototype.hasOwnProperty.call(payload, 'data')
|
|
1603
|
+
&& (Object.prototype.hasOwnProperty.call(payload, 'viewContext')
|
|
1604
|
+
|| Object.prototype.hasOwnProperty.call(payload, 'view'))) {
|
|
1605
|
+
return payload.data ?? {};
|
|
1606
|
+
}
|
|
1607
|
+
return payload;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* hydrateView — Hydrate một view đã được server-side render.
|
|
1612
|
+
*
|
|
1613
|
+
* SSR flow:
|
|
1614
|
+
* 1. Laravel render Blade template → HTML gửi về client
|
|
1615
|
+
* 2. Blade dùng $__VIEW_ID__ để prefix class của mỗi element (e.g. "v12345-af0882bc")
|
|
1616
|
+
* 3. Laravel truyền $__VIEW_ID__ về client qua page data (__SSR_VIEW_ID__)
|
|
1617
|
+
* 4. Client gọi hydrateView() — view được tạo với cùng viewId
|
|
1618
|
+
* 5. Html elements tìm server-rendered DOM nodes bằng class {viewId}-{elementId}
|
|
1619
|
+
* 6. Reactive regions claim server markers via markerRegistry.claim()
|
|
1620
|
+
* 7. Event handlers và state subscriptions được gắn vào DOM đã có
|
|
1621
|
+
*
|
|
1622
|
+
* Lưu ý: Không gây layout shift vì cấu trúc DOM được reuse (Html claim),
|
|
1623
|
+
* chỉ text nodes và markers được re-tạo (nội dung giống nhau nên không thấy được).
|
|
1624
|
+
*
|
|
1625
|
+
* @param name - View registry key (e.g. 'web.home')
|
|
1626
|
+
* @param data - View data, PHẢI chứa __SSR_VIEW_ID__ từ server
|
|
1627
|
+
* @param route - Active route (optional)
|
|
1628
|
+
*
|
|
1629
|
+
* Tham chiếu: COMPILER_CONTRACT.md §hydration, Html.ts §constructor
|
|
1630
|
+
*/
|
|
1631
|
+
/**
|
|
1632
|
+
* hydrateView — Hydrate một view đã được server-side render (SSR).
|
|
1633
|
+
*
|
|
1634
|
+
* Flow:
|
|
1635
|
+
* 1. Server (Laravel) render Blade → HTML gửi về client
|
|
1636
|
+
* 2. Blade dùng $__VIEW_ID__ prefix class mỗi element ("v12345-af0882bc")
|
|
1637
|
+
* 3. Client gọi hydrateView() với __SSR_VIEW_ID__ = cùng viewId
|
|
1638
|
+
* 4. Html elements claim server DOM nodes bằng class {viewId}-{elementId}
|
|
1639
|
+
* 5. Output claim comment markers + text nodes giữa markers
|
|
1640
|
+
* 6. Event handlers và state subscriptions gắn vào DOM có sẵn
|
|
1641
|
+
*
|
|
1642
|
+
* Khác mountView:
|
|
1643
|
+
* - Không xoá DOM cũ, không tạo HTML element mới (claim cái đã có)
|
|
1644
|
+
* - Markers (Reactive, Output) vẫn được tạo mới nếu server không emit
|
|
1645
|
+
* - Sau hydrate, SPA navigation dùng mountView bình thường (CSR)
|
|
1646
|
+
*/
|
|
1647
|
+
async hydrateView(
|
|
1648
|
+
name: string,
|
|
1649
|
+
data: Record<string, any> & { __SSR_VIEW_ID__: string },
|
|
1650
|
+
route?: ActiveRouteInterface,
|
|
1651
|
+
): Promise<any> {
|
|
1652
|
+
// Không có viewId từ server → không hydrate được, fallback CSR
|
|
1653
|
+
if (!data.__SSR_VIEW_ID__) {
|
|
1654
|
+
console.warn('[ViewManager] hydrateView: __SSR_VIEW_ID__ không có. Fallback → mountView.');
|
|
1655
|
+
return this.mountView(name, data, route);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const navigationGeneration = ++this.navigationGeneration;
|
|
1659
|
+
|
|
1660
|
+
// HTML server của lượt này là nguồn duy nhất để claim marker — bỏ index
|
|
1661
|
+
// của lượt hydrate trước (bfcache / test / re-hydrate) trước khi quét.
|
|
1662
|
+
markerRegistry.invalidateIndex();
|
|
1663
|
+
|
|
1664
|
+
const targetUrl = (route as any)?.$uri ?? route?.$urlPath ?? name;
|
|
1665
|
+
|
|
1666
|
+
// Router only supplies route params and the SSR id. Seed the page from
|
|
1667
|
+
// the matching server instance before constructing data-derived states.
|
|
1668
|
+
const { __SSR_VIEW_ID__: ssrViewId, ...routeData } = data;
|
|
1669
|
+
const key = this.resolveRegistryKey(name);
|
|
1670
|
+
const serverData = this.ssrViewData[key]?.instances?.[ssrViewId]?.data ?? {};
|
|
1671
|
+
const viewData = { ...serverData, ...routeData };
|
|
1672
|
+
// Compiled factories capture this id in their render closures; assigning
|
|
1673
|
+
// ctrl.viewId afterwards alone cannot update those closures.
|
|
1674
|
+
const constructorData = { ...viewData, __SSR_VIEW_ID__: ssrViewId };
|
|
1675
|
+
const view = await this.view(name, constructorData, false);
|
|
1676
|
+
// Keep the internal id out of application data after factory setup.
|
|
1677
|
+
delete (constructorData as Record<string, any>).__SSR_VIEW_ID__;
|
|
1678
|
+
if (!view) {
|
|
1679
|
+
this.showError(`hydrateView: View "${name}" không tìm thấy.`);
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
view.__ctrl__.urlPath = targetUrl;
|
|
1683
|
+
|
|
1684
|
+
// Ghi đè viewId bằng SSR viewId — đảm bảo Html elements claim đúng
|
|
1685
|
+
// server DOM nodes có class prefix = viewId (e.g. "vssr-abc12-page-root").
|
|
1686
|
+
// (Belt-and-suspenders: đúng cả khi factory không đọc __SSR_VIEW_ID__.)
|
|
1687
|
+
view.__ctrl__.viewId = ssrViewId;
|
|
1688
|
+
|
|
1689
|
+
// Render ở HYDRATE mode — elements claim DOM thay vì tạo mới
|
|
1690
|
+
const renderResult = await this.renderPageView(
|
|
1691
|
+
view, viewData, this.rootElement, InitModes.HYDRATE, false, 0, navigationGeneration
|
|
1692
|
+
);
|
|
1693
|
+
if (renderResult.type === 'cancelled') {
|
|
1694
|
+
view.__ctrl__.destroy();
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
if (renderResult.type === 'error') {
|
|
1698
|
+
view.__ctrl__.destroy();
|
|
1699
|
+
this.showError(renderResult.message);
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Lưới cuối như mountView — hydrate claim lỗi không được làm chết cả app.
|
|
1704
|
+
try {
|
|
1705
|
+
this.activateRenderedChain(renderResult, InitModes.HYDRATE);
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
logger.error(`Error hydrating view "${name}":`, err);
|
|
1708
|
+
this.showError(`Error hydrating view "${name}".`, err instanceof Error ? err.message : err);
|
|
1709
|
+
return null;
|
|
1710
|
+
} finally {
|
|
1711
|
+
// Hết lượt hydrate thì KHÔNG ai đọc index nữa (claim chỉ có ở đường
|
|
1712
|
+
// HYDRATE, mà mỗi lần nạp trang chỉ hydrate một lượt) — giữ lại chỉ
|
|
1713
|
+
// để Map trỏ vào comment của trang đầu suốt phiên: đo trên /docs
|
|
1714
|
+
// thấy 94/106 entry thành node rời DOM ngay sau lần SPA nav đầu.
|
|
1715
|
+
// Claim đến muộn (@await hydrate sau) vẫn đúng: miss sẽ tự dựng lại.
|
|
1716
|
+
markerRegistry.invalidateIndex();
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
return renderResult;
|
|
679
1720
|
}
|
|
680
1721
|
|
|
681
1722
|
// ─── Getters ────────────────────────────────────────────────
|
|
@@ -688,6 +1729,32 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
688
1729
|
return this.currentPageView;
|
|
689
1730
|
}
|
|
690
1731
|
|
|
1732
|
+
/** Layout chain đang mount, ngoài → trong (devtools/debug). */
|
|
1733
|
+
getLayoutChain(): ViewInterface[] {
|
|
1734
|
+
return [...this.currentLayoutChain];
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// ─── SSR boot ───────────────────────────────────────────────
|
|
1738
|
+
|
|
1739
|
+
/** Còn SSR boot chưa consume? (route đầu tiên nên hydrate thay vì mount) */
|
|
1740
|
+
hasSSRBoot(): boolean {
|
|
1741
|
+
return this.ssrBoot !== null;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Lấy viewId SSR cho một view name nếu nó là entry server đã render, rồi
|
|
1746
|
+
* CONSUME (xoá) — đảm bảo chỉ route ĐẦU TIÊN hydrate; navigate sau là CSR.
|
|
1747
|
+
* Trả null nếu không có SSR boot hoặc view không khớp entry.
|
|
1748
|
+
*/
|
|
1749
|
+
consumeSSRViewId(viewName: string): string | null {
|
|
1750
|
+
if (this.ssrBoot && this.ssrBoot.view === viewName) {
|
|
1751
|
+
const id = this.ssrBoot.viewId;
|
|
1752
|
+
this.ssrBoot = null;
|
|
1753
|
+
return id;
|
|
1754
|
+
}
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
691
1758
|
getViewStack(): ViewInterface[] {
|
|
692
1759
|
return this.viewStack;
|
|
693
1760
|
}
|
|
@@ -698,3 +1765,10 @@ export class ViewManager implements ViewManagerInterface {
|
|
|
698
1765
|
|
|
699
1766
|
|
|
700
1767
|
}
|
|
1768
|
+
|
|
1769
|
+
/** Remap only implicit page-data requests; explicit @await URLs keep their meaning. */
|
|
1770
|
+
export function resolveViewDataUrl(routeUrl: string, dataEndpoint?: string | null): string {
|
|
1771
|
+
if (!dataEndpoint) return routeUrl;
|
|
1772
|
+
const route = new URL(routeUrl, typeof window !== 'undefined' ? window.location.href : 'http://localhost');
|
|
1773
|
+
return dataEndpoint.replace(/\/+$/, '') + route.pathname + route.search;
|
|
1774
|
+
}
|