cabloy 5.1.176 → 5.1.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/package.json +1 -1
  4. package/repo-docs/backend/menu-guide.md +37 -3
  5. package/repo-e2e/specs/cabloy-basic.spec.ts +68 -1
  6. package/repo-e2e/specs/controller-boundary.spec.ts +42 -0
  7. package/repo-e2e/specs/routed-dialog.spec.ts +15 -0
  8. package/vona/packages-cli/cli/package.json +1 -1
  9. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +1 -1
  10. package/vona/packages-cli/cli-set-api/package.json +1 -1
  11. package/zova/packages-utils/zova-jsx/package.json +2 -2
  12. package/zova/packages-zova/zova/package.json +3 -3
  13. package/zova/packages-zova/zova-core/package.json +1 -1
  14. package/zova/packages-zova/zova-core/src/bean/beanContainer.ts +4 -0
  15. package/zova/packages-zova/zova-core/src/composables/useController.ts +4 -1
  16. package/zova/pnpm-lock.yaml +1 -1
  17. package/zova/src/suite/a-home/modules/home-indexadmin/src/page/dashboard/controller.tsx +31 -0
  18. package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx +18 -2
  19. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  20. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +13 -22
  21. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/sys.router.ts +1 -0
  22. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routerGuardDisposers.ts +19 -0
  23. package/zova/src/suite-vendor/a-zova/modules/a-router/src/monkey.ts +1 -0
  24. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +12 -3
  25. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/pageMeta.ts +6 -1
  26. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routerView.ts +5 -0
  27. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routerGuardDisposers.test.ts +24 -0
  28. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  29. package/zova/src/suite-vendor/a-zova/package.json +3 -3
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.176
1
+ 5.1.178
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.178
4
+
5
+ ### Features
6
+
7
+ - Add `setPageMeta` support for configuring page metadata.
8
+
9
+ ### Bug Fixes
10
+
11
+ - Fix router behavior.
12
+ - Correct `isAttemptActive` handling.
13
+ - Ensure route resources are ready before initializing controller data.
14
+
15
+ ## 5.1.177
16
+
17
+ ### Improvements
18
+
19
+ - Update the menu guide documentation.
20
+ - Refresh the pnpm lockfile.
21
+
3
22
  ## 5.1.176
4
23
 
5
24
  ### Improvements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.176",
3
+ "version": "5.1.178",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -55,9 +55,11 @@ This makes menu retrieval part of the broader backend contract surface.
55
55
 
56
56
  In the current repo implementation, the out-of-the-box menu controller is public and delegates directly to `this.scope.service.menu.retrieveMenus(publicPath)`.
57
57
 
58
- ## Static menu visibility
58
+ ## SSR Site targeting
59
59
 
60
- `@SsrMenu(...)` items can declare static role visibility without changing the public menu DTO:
60
+ `@SsrMenu(...)` and `@SsrMenuGroup(...)` use `site` to select the SSR Site onion(s) that receive the declaration. It is a navigation-composition filter, not a site-admission, role-visibility, or backend-authorization decision.
61
+
62
+ Use `site` for navigation owned by one SSR Site or deliberately exclusive to a known subset of sites:
61
63
 
62
64
  ```typescript
63
65
  @SsrMenu({
@@ -66,10 +68,41 @@ In the current repo implementation, the out-of-the-box menu controller is public
66
68
  link: 'presetResource',
67
69
  roles: ['systemAdmin'],
68
70
  },
69
- site: 'basic-siteadmin:admin',
71
+ // This Basic Admin-owned entry intentionally targets one SSR Site.
72
+ site: ['basic-siteadmin:admin'],
73
+ })
74
+ ```
75
+
76
+ For a shared module declaration intended for every compatible SSR Admin site, omit `site`:
77
+
78
+ ```typescript
79
+ @SsrMenu({
80
+ item: {
81
+ title: $locale('SharedOperations'),
82
+ link: 'presetResource',
83
+ roles: ['systemAdmin'],
84
+ },
85
+ // No `site`: the shared declaration is available to compatible SSR Admin sites.
70
86
  })
71
87
  ```
72
88
 
89
+ Do not bind a shared Admin menu or menu group to the current/default Admin site merely to keep a new independent site's navigation narrow. Isolate an independent site through its enabled module composition and site-owned menus/groups. When a shared capability needs genuinely different navigation contracts per site, use an explicit, approved site-specific composition or owner instead of narrowing the shared declaration by default.
90
+
91
+ `site` is distinct from a role's `siteIds`, menu `roles` or role-menu associations, and Passport/RBAC authorization:
92
+
93
+ ```text
94
+ site -> which SSR Site receives a menu/group declaration
95
+ siteIds -> which frontend Site a subject may enter
96
+ roles / role-menu -> which admitted subject may discover a menu leaf
97
+ Passport / RBAC -> which backend actions or data the subject may use
98
+ ```
99
+
100
+ See [Menu Authorization](/backend/menu-authorization) for the site-admission, navigation-disclosure, and backend-authority boundaries.
101
+
102
+ ## Static menu visibility
103
+
104
+ `@SsrMenu(...)` items can declare static role visibility without changing the public menu DTO:
105
+
73
106
  - Omit `roles` to make an item visible to anonymous and authenticated callers.
74
107
  - Use `roles: []` when an item has no static role visibility and is intended to be disclosed only through dynamic Role-menu configuration.
75
108
  - A nonempty `roles` array is visible when the current Passport has at least one matching role name; it can also be disclosed through dynamic Role-menu configuration.
@@ -107,5 +140,6 @@ When editing SSR menu behavior, ask:
107
140
  2. is there a default fallback menu that should remain available?
108
141
  3. does the menu contract belong in backend API design, frontend route design, or both?
109
142
  4. does the active edition affect the menu structure or public path assumptions?
143
+ 5. is this declaration site-owned or deliberately site-exclusive (specify `site`), or shared across compatible SSR Admin sites (omit `site`)?
110
144
 
111
145
  That helps AI keep menu behavior aligned with Cabloy’s shared SSR architecture.
@@ -28,6 +28,16 @@ function collectPageErrors(page: Page) {
28
28
  return errors;
29
29
  }
30
30
 
31
+ function collectConsoleErrors(page: Page) {
32
+ const errors: string[] = [];
33
+ page.on('console', message => {
34
+ if (message.type() === 'error') {
35
+ errors.push(message.text());
36
+ }
37
+ });
38
+ return errors;
39
+ }
40
+
31
41
  async function loginAsAdmin(page: Page) {
32
42
  await page.goto('/admin/', { waitUntil: 'load' });
33
43
  if (page.url().includes('/admin/login')) {
@@ -319,7 +329,64 @@ test(
319
329
  );
320
330
 
321
331
  test(
322
- 'ATP-BASIC-FLOW-01: Training Student flow filter wraps and submits queries',
332
+ 'ATP-BASIC-FLOW-01: cold Admin navigation initializes dynamic resource selectors',
333
+ { tag: ['@admin', '@flow'] },
334
+ async ({ browser }) => {
335
+ const context = await browser.newContext();
336
+ const page = await context.newPage();
337
+ const pageErrors = collectPageErrors(page);
338
+ const consoleErrors = collectConsoleErrors(page);
339
+ try {
340
+ await page.goto('/admin/login', { waitUntil: 'load' });
341
+ await page.evaluate(() => {
342
+ indexedDB.deleteDatabase('localforage');
343
+ localStorage.clear();
344
+ sessionStorage.clear();
345
+ });
346
+ await page.goto('/admin/login?returnTo=/rest/resource/training-student%253Astudent', {
347
+ waitUntil: 'load',
348
+ });
349
+ await page.getByPlaceholder('Your Username').fill('admin');
350
+ await page.getByPlaceholder('Your Password').fill('123456');
351
+ await expect(page.getByPlaceholder('Please input captcha')).not.toHaveValue('');
352
+
353
+ const initialSelect = waitForStudentSelect(page);
354
+ await page.getByRole('button', { name: 'Login', exact: true }).click();
355
+ try {
356
+ await expect(page).toHaveURL(studentResourceUrl);
357
+ await initialSelect;
358
+ } finally {
359
+ await initialSelect.catch(() => {});
360
+ }
361
+ await expect(page.getByLabel('Student Name')).toBeVisible();
362
+
363
+ await page.getByRole('link', { name: 'Home', exact: true }).click();
364
+ await expect(page).toHaveURL(/\/admin\/(?:\?|$)/);
365
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
366
+ await expect(
367
+ page.getByRole('heading', { name: 'Vona integrated SSR', exact: true }),
368
+ ).toBeVisible();
369
+ await expect(
370
+ page.getByRole('heading', { name: 'Zova standalone SSR', exact: true }),
371
+ ).toBeVisible();
372
+ await expect(page.locator('body')).toBeVisible();
373
+
374
+ expect(pageErrors).toEqual([]);
375
+ expect(
376
+ consoleErrors.some(
377
+ error =>
378
+ error.includes("Cannot read properties of undefined (reading 'resource')") ||
379
+ error.includes('resource not specified'),
380
+ ),
381
+ ).toBeFalsy();
382
+ } finally {
383
+ await context.close();
384
+ }
385
+ },
386
+ );
387
+
388
+ test(
389
+ 'ATP-BASIC-FLOW-02: Training Student flow filter wraps and submits queries',
323
390
  { tag: ['@admin', '@flow'] },
324
391
  async ({ page }) => {
325
392
  await page.setViewportSize({ width: 1440, height: 900 });
@@ -101,6 +101,48 @@ test(
101
101
  },
102
102
  );
103
103
 
104
+ test(
105
+ 'ATP-BASIC-CONTROLLER-BOUNDARY-06: disposed and stale loads do not surface errors',
106
+ { tag: ['@web', '@flow'] },
107
+ async ({ page }) => {
108
+ const pageErrors = collectPageErrors(page);
109
+ const consoleErrors = collectConsoleErrors(page);
110
+ const response = await page.goto('/demo/basic/controllerBoundary', { waitUntil: 'load' });
111
+ expect(response?.ok()).toBeTruthy();
112
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'web');
113
+ await page.waitForTimeout(1_000);
114
+
115
+ const loading = page.getByRole('status');
116
+ const error = page.locator('.alert.alert-error');
117
+ const ready = page.getByText('Controller boundary probe ready', { exact: true });
118
+
119
+ const disposedLoading = loading.waitFor({ state: 'visible' });
120
+ await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
121
+ await disposedLoading;
122
+ await page.getByRole('button', { name: 'Clear probe', exact: true }).click();
123
+ await page.waitForTimeout(1_000);
124
+ await expect(loading).toHaveCount(0);
125
+ await expect(error).toHaveCount(0);
126
+ await expect(ready).toHaveCount(0);
127
+
128
+ const staleLoading = loading.waitFor({ state: 'visible' });
129
+ await page.getByRole('button', { name: 'Mount failing probe', exact: true }).click();
130
+ await staleLoading;
131
+ await page.getByRole('button', { name: 'Mount fast success probe', exact: true }).click();
132
+ await expect(ready).toBeVisible();
133
+ await page.waitForTimeout(1_000);
134
+ await expect(error).toHaveCount(0);
135
+ await expect(ready).toBeVisible();
136
+
137
+ expect(pageErrors).toEqual([]);
138
+ expect(
139
+ consoleErrors.filter(error =>
140
+ /Cannot read properties of null \(reading ['"]state['"]\)/.test(error),
141
+ ),
142
+ ).toEqual([]);
143
+ },
144
+ );
145
+
104
146
  test(
105
147
  'ATP-BASIC-CONTROLLER-BOUNDARY-02: inline boundaries preserve phrasing-content markup',
106
148
  { tag: ['@web', '@flow'] },
@@ -195,6 +195,21 @@ test(
195
195
  await expect(dialogA.getByRole('button', { name: 'Back', exact: true })).toHaveCount(0);
196
196
  expect(page.url()).toBe(browserUrl);
197
197
 
198
+ await page.keyboard.press('Escape');
199
+ await expect(dialogA).toHaveCount(0);
200
+ expect(page.url()).toBe(browserUrl);
201
+
202
+ await page.getByRole('button', { name: 'Open A', exact: true }).click();
203
+ await expect(dialogA).toHaveCount(1);
204
+ await expect(
205
+ dialogA.getByRole('heading', { name: 'Routed Dialog Entry', exact: true }),
206
+ ).toBeVisible();
207
+ await dialogA.getByRole('button', { name: 'Local push to detail', exact: true }).click();
208
+ await expect(
209
+ dialogA.getByRole('heading', { name: 'Routed Dialog Detail', exact: true }),
210
+ ).toBeVisible();
211
+ expect(page.url()).toBe(browserUrl);
212
+
198
213
  await page.keyboard.press('Escape');
199
214
  await expect(dialogA).toHaveCount(0);
200
215
  expect(page.url()).toBe(browserUrl);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.150",
3
+ "version": "1.1.151",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -65,5 +65,5 @@ export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
65
65
  @Passport.systemAdmin()
66
66
  async deleteBulk(@Arg.body() command: Dto<%=argv.resourceNameCapitalize%>DeleteBulk): Promise<void> {
67
67
  await this.scope.service.<%=argv.resourceName%>.deleteBulk(command.ids);
68
- }
68
+ }
69
69
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli-set-api",
3
- "version": "1.1.148",
3
+ "version": "1.1.149",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli-set-api",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-jsx",
3
- "version": "1.1.90",
3
+ "version": "1.1.91",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "Zova JSX",
6
6
  "keywords": [
@@ -50,7 +50,7 @@
50
50
  "@cabloy/word-utils": "^2.1.14",
51
51
  "typestyle": "^2.4.0",
52
52
  "vue": "^3.5.38",
53
- "zova-core": "^5.1.90"
53
+ "zova-core": "^5.1.91"
54
54
  },
55
55
  "devDependencies": {
56
56
  "clean-package": "^2.2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova",
3
- "version": "5.1.164",
3
+ "version": "5.1.165",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A vue3 framework with ioc",
6
6
  "keywords": [
@@ -45,8 +45,8 @@
45
45
  "postpack": "clean-package restore"
46
46
  },
47
47
  "dependencies": {
48
- "zova-core": "^5.1.90",
49
- "zova-suite-a-zova": "^5.1.163"
48
+ "zova-core": "^5.1.91",
49
+ "zova-suite-a-zova": "^5.1.164"
50
50
  },
51
51
  "devDependencies": {
52
52
  "clean-package": "^2.2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-core",
3
- "version": "5.1.90",
3
+ "version": "5.1.91",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A vue3 framework with ioc",
6
6
  "keywords": [
@@ -489,6 +489,10 @@ export class BeanContainer {
489
489
  // throw new Error(`bean not found: ${beanFullName}`);
490
490
  return null!;
491
491
  }
492
+ // controller module: ensure route resources are ready before controller data initialization
493
+ if (controllerData) {
494
+ await this._useModule(beanOptions.module);
495
+ }
492
496
  // beanFullName
493
497
  return await this._createBeanInstance<T>(
494
498
  record,
@@ -111,6 +111,7 @@ async function _useController(
111
111
  }
112
112
 
113
113
  function isAttemptActive(attemptId: number) {
114
+ if (ctx.disposed || !ctx.meta) return false;
114
115
  return ctx.meta.state.isLoadAttemptActive(attemptId);
115
116
  }
116
117
 
@@ -133,9 +134,9 @@ async function _useController(
133
134
  true,
134
135
  false,
135
136
  controller => {
137
+ if (!isAttemptActive(attemptId)) return;
136
138
  loadBeans.add(controller as object);
137
139
  ctx.meta.state.setLoadBeanAttempt(controller as object, attemptId);
138
- if (!isAttemptActive(attemptId)) return;
139
140
  ctx.meta.state.setLoadController(controller);
140
141
  setLoadBoundaryOptions((controller as any).constructor);
141
142
  },
@@ -151,6 +152,7 @@ async function _useController(
151
152
  true,
152
153
  false,
153
154
  style => {
155
+ if (!isAttemptActive(attemptId)) return;
154
156
  loadBeans.add(style as object);
155
157
  ctx.meta.state.setLoadBeanAttempt(style as object, attemptId);
156
158
  },
@@ -167,6 +169,7 @@ async function _useController(
167
169
  true,
168
170
  false,
169
171
  render => {
172
+ if (!isAttemptActive(attemptId)) return;
170
173
  loadBeans.add(render as object);
171
174
  ctx.meta.state.setLoadBeanAttempt(render as object, attemptId);
172
175
  },
@@ -1019,7 +1019,7 @@ importers:
1019
1019
  src/suite-vendor/a-pay:
1020
1020
  dependencies:
1021
1021
  zova-module-a-pay:
1022
- specifier: ^5.0.8
1022
+ specifier: ^5.0.9
1023
1023
  version: link:modules/a-pay
1024
1024
  zova-module-pay-mock:
1025
1025
  specifier: ^5.0.5
@@ -4,6 +4,37 @@ import { ZPage, ZSiteEntryTables } from 'zova-module-home-base';
4
4
 
5
5
  @Controller()
6
6
  export class ControllerPageDashboard extends BeanControllerPageBase {
7
+ private _timer?: number;
8
+ currentTime = '';
9
+
10
+ protected __init__() {
11
+ if (!this.$pageRoute) return;
12
+ this.$ssr.handleDirectOrOnHydrated(() => {
13
+ this.currentTime = new Date().toLocaleTimeString();
14
+ this._timer = window.setInterval(() => {
15
+ this.currentTime = new Date().toLocaleTimeString();
16
+ }, 1000);
17
+ });
18
+ this.$router.setPageMeta(this.$pageRoute, {
19
+ onCustomRenderIsolate: () => (
20
+ <div class="flex w-full items-center justify-center">
21
+ <div class="badge badge-lg gap-2 border border-primary/30 bg-primary/10 px-4 py-3 text-primary shadow-sm">
22
+ <span class="h-2 w-2 animate-pulse rounded-full bg-primary" aria-hidden="true"></span>
23
+ <span class="font-mono text-base font-semibold tracking-wide tabular-nums">
24
+ {this.currentTime || '--:--:--'}
25
+ </span>
26
+ </div>
27
+ </div>
28
+ ),
29
+ });
30
+ }
31
+
32
+ protected __dispose__() {
33
+ if (this._timer !== undefined) {
34
+ window.clearInterval(this._timer);
35
+ }
36
+ }
37
+
7
38
  protected render() {
8
39
  return (
9
40
  <ZPage>
@@ -63,6 +63,20 @@ export class RenderTabs extends BeanRenderBase {
63
63
  const tabCurrent = $$modelTabs.tabCurrent;
64
64
  if (!tabCurrent || !tabCurrent.items) return;
65
65
  const tabKey = tabCurrent.tabKey;
66
+ const tabItemAnchor = tabCurrent.items.find(item => item.componentKey === tabKey);
67
+ const onCustomRenderIsolate = tabItemAnchor?.pageMeta?.onCustomRenderIsolate;
68
+ if (onCustomRenderIsolate) {
69
+ const domContent = onCustomRenderIsolate(tabCurrent);
70
+ if (!this.$$modelTabs.cache) return domContent;
71
+ return <ClientOnly>{domContent}</ClientOnly>;
72
+ }
73
+ const pageTitleAnchor = tabItemAnchor?.pageMeta?.pageTitle;
74
+ if (pageTitleAnchor) {
75
+ const domWrapper = <div class="flex items-center justify-center">{pageTitleAnchor}</div>;
76
+ if (!this.$$modelTabs.cache) return domWrapper;
77
+ return <ClientOnly>{domWrapper}</ClientOnly>;
78
+ }
79
+
66
80
  const domTabs: VNode[] = [];
67
81
  for (const tabItem of tabCurrent.items) {
68
82
  // ignore first
@@ -70,7 +84,9 @@ export class RenderTabs extends BeanRenderBase {
70
84
  const { componentKey, pageMeta } = tabItem;
71
85
  const className =
72
86
  componentKey === $$modelTabs.componentKeyCurrent ? 'tab-active text-primary' : '';
73
- const pageTitle = pageMeta?.pageTitle || '';
87
+ const pageContent = pageMeta?.onCustomRender
88
+ ? pageMeta.onCustomRender(tabItem)
89
+ : pageMeta?.pageTitle || '';
74
90
  const tabItemIcon = this.getTabItemIcon(tabItem);
75
91
  const domTab = (
76
92
  <a
@@ -86,7 +102,7 @@ export class RenderTabs extends BeanRenderBase {
86
102
  class="overflow-hidden text-ellipsis whitespace-nowrap"
87
103
  style={{ display: 'inline-block', maxWidth: this.scope.config.tabItem.maxWidth }}
88
104
  >
89
- {pageTitle}
105
+ {pageContent}
90
106
  </div>
91
107
  <ZIcon
92
108
  class="tab-close hidden hover:bg-slate-400 rounded-sm"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-router",
3
- "version": "5.1.39",
3
+ "version": "5.1.40",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "router",
6
6
  "keywords": [
@@ -6,9 +6,10 @@ import type {
6
6
  RouterOptions,
7
7
  } from '@cabloy/vue-router';
8
8
 
9
- import { BeanBase, TypeEventOff, Use } from 'zova';
9
+ import { BeanBase, Use } from 'zova';
10
10
  import { Bean } from 'zova-module-a-bean';
11
11
 
12
+ import { RouterGuardDisposers } from '../lib/routerGuardDisposers.js';
12
13
  import { BeanRouterViewBase } from '../lib/routerViewBase.js';
13
14
  import { ModelPageData } from '../model/pageData.js';
14
15
  import { IPageMeta } from '../types/pageMeta.js';
@@ -30,7 +31,7 @@ export interface BeanRouter extends Omit<
30
31
  @Bean()
31
32
  export class BeanRouter extends BeanBase {
32
33
  private _vueRouterApp: Router;
33
- private _eventRouterGuards: TypeEventOff[] = [];
34
+ private _routerGuardDisposers = new RouterGuardDisposers();
34
35
  private _routerViews: BeanRouterViewBase[] = [];
35
36
  private _disposed = false;
36
37
 
@@ -65,16 +66,9 @@ export class BeanRouter extends BeanBase {
65
66
  }
66
67
 
67
68
  public dispose() {
68
- this.__dispose__();
69
- }
70
-
71
- protected __dispose__() {
72
69
  if (this._disposed) return;
73
70
  this._disposed = true;
74
- for (const fn of this._eventRouterGuards) {
75
- fn();
76
- }
77
- this._eventRouterGuards = [];
71
+ this._routerGuardDisposers.dispose();
78
72
  this._routerViews = [];
79
73
  const router = this._vueRouterApp as Router & {
80
74
  __prepareNavigation?: (to: unknown) => Promise<void> | void;
@@ -82,6 +76,11 @@ export class BeanRouter extends BeanBase {
82
76
  if (router) router.__prepareNavigation = undefined;
83
77
  }
84
78
 
79
+ protected __dispose__() {
80
+ // Router instances can be borrowed through page host providers. Their
81
+ // creator explicitly calls dispose() when the router itself is finished.
82
+ }
83
+
85
84
  protected __get__(prop: string) {
86
85
  // SymbolRouter first
87
86
  const value = this._vueRouterApp?.[prop];
@@ -140,27 +139,19 @@ export class BeanRouter extends BeanBase {
140
139
  }
141
140
 
142
141
  beforeEach(guard: NavigationGuardWithThis<undefined>): () => void {
143
- const fn = this._vueRouterApp.beforeEach(guard);
144
- this._eventRouterGuards.push(fn);
145
- return fn;
142
+ return this._routerGuardDisposers.add(this._vueRouterApp.beforeEach(guard));
146
143
  }
147
144
 
148
145
  beforeResolve(guard: NavigationGuardWithThis<undefined>): () => void {
149
- const fn = this._vueRouterApp.beforeResolve(guard);
150
- this._eventRouterGuards.push(fn);
151
- return fn;
146
+ return this._routerGuardDisposers.add(this._vueRouterApp.beforeResolve(guard));
152
147
  }
153
148
 
154
149
  afterEach(guard: NavigationHookAfter): () => void {
155
- const fn = this._vueRouterApp.afterEach(guard);
156
- this._eventRouterGuards.push(fn);
157
- return fn;
150
+ return this._routerGuardDisposers.add(this._vueRouterApp.afterEach(guard));
158
151
  }
159
152
 
160
153
  onError(handler: TypeErrorListener): () => void {
161
- const fn = this._vueRouterApp.onError(handler);
162
- this._eventRouterGuards.push(fn);
163
- return fn;
154
+ return this._routerGuardDisposers.add(this._vueRouterApp.onError(handler));
164
155
  }
165
156
 
166
157
  setPageMeta(route: RouteLocationNormalizedLoadedGeneric, pageMeta: IPageMeta) {
@@ -388,6 +388,7 @@ export class SysRouter extends BeanBase {
388
388
  routeData = {
389
389
  name: routeNameParent,
390
390
  path,
391
+ ...(route.alias ? { alias: route.alias } : {}),
391
392
  component: this.createAsyncComponent(layout as any),
392
393
  children: [{ ...route, name, path: '', component, meta }],
393
394
  };
@@ -0,0 +1,19 @@
1
+ export class RouterGuardDisposers {
2
+ private _disposers: (() => void)[] = [];
3
+ private _disposed = false;
4
+
5
+ add(disposer: () => void) {
6
+ this._disposers.push(disposer);
7
+ return disposer;
8
+ }
9
+
10
+ dispose() {
11
+ if (this._disposed) return;
12
+ this._disposed = true;
13
+ const disposers = this._disposers;
14
+ this._disposers = [];
15
+ for (const disposer of disposers) {
16
+ disposer();
17
+ }
18
+ }
19
+ }
@@ -99,6 +99,7 @@ export class Monkey
99
99
  if (this.serviceRouterGuards) {
100
100
  this.serviceRouterGuards.dispose();
101
101
  }
102
+ this._beanRouter?.dispose();
102
103
  }
103
104
 
104
105
  async appReady() {
@@ -12,9 +12,18 @@ export class ServiceRouterGuards extends BeanRouterGuardsBase {
12
12
  const self = this;
13
13
  router.beforeEach(async to => {
14
14
  // match path
15
- let match = to.matched.find(item => item.aliasOf);
16
- if (match) {
17
- match = match.aliasOf;
15
+ const aliasMatch = to.matched.find(item => item.aliasOf);
16
+ let match = aliasMatch;
17
+ if (aliasMatch) {
18
+ match = aliasMatch.aliasOf;
19
+ // A path-config alias can initially resolve to a componentless placeholder
20
+ // before its module registers the real layout route. Reload the same path
21
+ // after loading so the router resolves the newly registered component.
22
+ if (!match?.components || Object.keys(match.components).length === 0) {
23
+ const resLoadModule = await this._forceLoadModule(router, match?.name, match?.path);
24
+ if (resLoadModule && resLoadModule !== true) return resLoadModule;
25
+ return to.fullPath;
26
+ }
18
27
  } else {
19
28
  match = to.matched[to.matched.length - 1];
20
29
  // prepareCheck
@@ -1,7 +1,12 @@
1
- import { IFormMeta } from 'zova-module-a-openapi';
1
+ import type { VNodeChild } from 'vue';
2
+ import type { IFormMeta } from 'zova-module-a-openapi';
3
+
4
+ import type { IRouteViewRouteItem, IRouteViewTabCurrent } from './routerView.js';
2
5
 
3
6
  export interface IPageMeta {
4
7
  pageTitle?: string;
5
8
  pageDirty?: boolean;
6
9
  formMeta?: IFormMeta;
10
+ onCustomRender?: (tabItem: IRouteViewRouteItem) => VNodeChild;
11
+ onCustomRenderIsolate?: (tabCurrent: IRouteViewTabCurrent) => VNodeChild;
7
12
  }
@@ -14,6 +14,11 @@ export interface IRouteViewRouteItem extends IRouteViewRouteItemBase {
14
14
  pageMeta?: IPageMeta;
15
15
  }
16
16
 
17
+ export interface IRouteViewTabCurrent {
18
+ tabKey: string;
19
+ items?: IRouteViewRouteItem[];
20
+ }
21
+
17
22
  export interface IRouteViewRouteMeta extends IRouteViewRouteItemBase {
18
23
  tabKey: string;
19
24
  }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { RouterGuardDisposers } from '../../src/lib/routerGuardDisposers.js';
5
+
6
+ test('router guard disposers release every callback only once', () => {
7
+ const disposers = new RouterGuardDisposers();
8
+ const calls: string[] = [];
9
+
10
+ const beforeEach = () => calls.push('beforeEach');
11
+ const beforeResolve = () => calls.push('beforeResolve');
12
+ const afterEach = () => calls.push('afterEach');
13
+ const onError = () => calls.push('onError');
14
+
15
+ assert.equal(disposers.add(beforeEach), beforeEach);
16
+ assert.equal(disposers.add(beforeResolve), beforeResolve);
17
+ assert.equal(disposers.add(afterEach), afterEach);
18
+ assert.equal(disposers.add(onError), onError);
19
+
20
+ disposers.dispose();
21
+ disposers.dispose();
22
+
23
+ assert.deepEqual(calls, ['beforeEach', 'beforeResolve', 'afterEach', 'onError']);
24
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-zova",
3
- "version": "5.1.106",
3
+ "version": "5.1.107",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "zova",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "@cabloy/word-utils": "^2.1.14",
44
44
  "defu": "^6.1.7",
45
45
  "luxon": "^3.7.2",
46
- "zova-jsx": "^1.1.90"
46
+ "zova-jsx": "^1.1.91"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@cabloy/cli": "^3.1.29",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-suite-a-zova",
3
- "version": "5.1.163",
3
+ "version": "5.1.164",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "zova",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "zova-module-a-meta": "^5.1.22",
23
23
  "zova-module-a-model": "^5.1.37",
24
24
  "zova-module-a-openapi": "^5.1.54",
25
- "zova-module-a-router": "^5.1.39",
25
+ "zova-module-a-router": "^5.1.40",
26
26
  "zova-module-a-routerstack": "^5.1.29",
27
27
  "zova-module-a-routertabs": "^5.1.34",
28
28
  "zova-module-a-ssr": "^5.1.40",
@@ -31,7 +31,7 @@
31
31
  "zova-module-a-style": "^5.1.35",
32
32
  "zova-module-a-table": "^5.1.43",
33
33
  "zova-module-a-zod": "^5.1.37",
34
- "zova-module-a-zova": "^5.1.106"
34
+ "zova-module-a-zova": "^5.1.107"
35
35
  },
36
36
  "title": "a-zova"
37
37
  }