cabloy 5.1.207 → 5.1.209

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 (97) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +9 -9
  6. package/repo-agent-governance/policies/repository.md +1 -1
  7. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  8. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  9. package/repo-docs/backend/unit-testing.md +1 -1
  10. package/repo-docs/frontend/form-guide.md +17 -1
  11. package/repo-docs/frontend/model-architecture.md +4 -0
  12. package/repo-docs/frontend/model-state-guide.md +23 -2
  13. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  14. package/repo-docs/frontend/scripts.md +3 -3
  15. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  17. package/repo-docs/fullstack/quickstart.md +11 -1
  18. package/repo-docs/reference/repo-scripts.md +19 -5
  19. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  20. package/vona/packages-vona/vona/package.json +1 -1
  21. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  22. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  24. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  27. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  30. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  31. package/zova/packages-cli/cli/package.json +2 -2
  32. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  33. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  34. package/zova/packages-cli/cli-set-front/package.json +1 -1
  35. package/zova/packages-zova/zova/package.json +2 -2
  36. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  37. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  38. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  39. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  40. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  58. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  60. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  61. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  67. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +994 -0
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  82. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  95. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  97. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { getPersistenceQueue, PersistenceQueue } from '../../src/lib/persistenceQueue.js';
5
+
6
+ function deferred() {
7
+ let resolve!: () => void;
8
+ const promise = new Promise<void>(done => {
9
+ resolve = done;
10
+ });
11
+ return { promise, resolve };
12
+ }
13
+
14
+ test('async saves finish before removal and flush waits for durable completion', async () => {
15
+ const queue = new PersistenceQueue();
16
+ const write = deferred();
17
+ const calls: string[] = [];
18
+ let stored: string | undefined;
19
+ const save = queue.enqueue(async () => {
20
+ calls.push('save');
21
+ await write.promise;
22
+ stored = 'draft';
23
+ });
24
+ const remove = queue.enqueue(() => {
25
+ calls.push('remove');
26
+ stored = undefined;
27
+ });
28
+ let flushed = false;
29
+ const flush = queue.flush().then(() => {
30
+ flushed = true;
31
+ });
32
+ await Promise.resolve();
33
+ assert.deepEqual(calls, ['save']);
34
+ assert.equal(flushed, false);
35
+ write.resolve();
36
+ await Promise.all([save, remove, flush]);
37
+ assert.deepEqual(calls, ['save', 'remove']);
38
+ assert.equal(stored, undefined);
39
+ assert.equal(flushed, true);
40
+ });
41
+
42
+ test('an unawaited storage failure is observed by flush without poisoning later operations', async () => {
43
+ const queue = new PersistenceQueue();
44
+ const error = new Error('storage unavailable');
45
+ queue.enqueue(() => {
46
+ throw error;
47
+ });
48
+ let saved = false;
49
+ await queue.enqueue(() => {
50
+ saved = true;
51
+ });
52
+ assert.equal(saved, true);
53
+ await assert.rejects(queue.flush(), failure => failure === error);
54
+ await queue.flush();
55
+ await queue.enqueue(() => {
56
+ saved = false;
57
+ });
58
+ await queue.flush();
59
+ assert.equal(saved, false);
60
+ });
61
+
62
+ test('save serialization failures are awaited and the queue recovers', async () => {
63
+ const queue = new PersistenceQueue();
64
+ const error = new Error('serialization failed');
65
+ await assert.rejects(
66
+ queue.enqueue(() => {
67
+ throw error;
68
+ }),
69
+ failure => failure === error,
70
+ );
71
+ await assert.rejects(queue.flush(), failure => failure === error);
72
+ await queue.enqueue(() => undefined);
73
+ await queue.flush();
74
+ });
75
+
76
+ test('a clear barrier drains earlier writes and invalidates delayed writers and restores', async () => {
77
+ const queue = new PersistenceQueue();
78
+ const write = deferred();
79
+ let stored: string | undefined;
80
+ const generation = queue.generation('draft');
81
+ const save = queue.enqueue(async () => {
82
+ await write.promise;
83
+ stored = 'accepted draft';
84
+ });
85
+ queue.invalidateAll();
86
+ const clear = queue.enqueue(() => {
87
+ stored = undefined;
88
+ });
89
+ const lateSave = queue.enqueue(() => {
90
+ if (generation === queue.generation('draft')) stored = 'late draft';
91
+ });
92
+ assert.notEqual(generation, queue.generation('draft'));
93
+ write.resolve();
94
+ await Promise.all([save, clear, lateSave]);
95
+ await queue.flush();
96
+ assert.equal(stored, undefined);
97
+ });
98
+
99
+ test('per-key invalidation discards a late restore without affecting other keys', async () => {
100
+ const queue = new PersistenceQueue();
101
+ const read = deferred();
102
+ const firstGeneration = queue.generation('first');
103
+ const secondGeneration = queue.generation('second');
104
+ const restore = queue.enqueue(async () => {
105
+ await read.promise;
106
+ return firstGeneration === queue.generation('first') ? 'old data' : undefined;
107
+ });
108
+ await Promise.resolve();
109
+ queue.invalidate('first');
110
+ read.resolve();
111
+ assert.equal(await restore, undefined);
112
+ assert.equal(secondGeneration, queue.generation('second'));
113
+ });
114
+
115
+ test('all models using one storage share ordering while separate stores remain independent', async () => {
116
+ const storage = {};
117
+ const otherStorage = {};
118
+ const first = getPersistenceQueue(storage);
119
+ assert.equal(first, getPersistenceQueue(storage));
120
+ assert.notEqual(first, getPersistenceQueue(otherStorage));
121
+ const pending = deferred();
122
+ const calls: string[] = [];
123
+ const save = first.enqueue(async () => {
124
+ await pending.promise;
125
+ calls.push('save');
126
+ });
127
+ const clear = getPersistenceQueue(storage).enqueue(() => {
128
+ calls.push('clear');
129
+ });
130
+ await getPersistenceQueue(otherStorage).enqueue(() => {
131
+ calls.push('other');
132
+ });
133
+ assert.deepEqual(calls, ['other']);
134
+ pending.resolve();
135
+ await Promise.all([save, clear]);
136
+ assert.deepEqual(calls, ['other', 'save', 'clear']);
137
+ });
@@ -0,0 +1,129 @@
1
+ import { QueryClient, QueryObserver } from '@tanstack/query-core';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { stripTypeScriptTypes } from 'node:module';
5
+ import test from 'node:test';
6
+
7
+ import { ModelPersistenceContext } from '../../src/lib/modelPersistence.js';
8
+
9
+ const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0));
10
+
11
+ function deferred<T>() {
12
+ let resolve!: (value: T) => void;
13
+ const promise = new Promise<T>(done => {
14
+ resolve = done;
15
+ });
16
+ return { promise, resolve };
17
+ }
18
+
19
+ test('outgoing transition cancels and resets immediately without active refetch; later login still refetches', async () => {
20
+ const client = new QueryClient({
21
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
22
+ });
23
+ const persistence = new ModelPersistenceContext(client, 'admin');
24
+ persistence.update({ instanceId: 2, siteId: 'admin', userId: 1 }, { initial: true });
25
+ await tick();
26
+ const outgoing = deferred<string>();
27
+ let accessToken: string | undefined = 'token-1';
28
+ const requests: Array<string | undefined> = [];
29
+ let aborted = false;
30
+ const observer = new QueryObserver(client, {
31
+ queryKey: ['protected'],
32
+ initialData: 'private',
33
+ queryFn: ({ signal }) => {
34
+ requests.push(accessToken);
35
+ if (requests.length === 1) {
36
+ signal.addEventListener('abort', () => {
37
+ aborted = true;
38
+ });
39
+ return outgoing.promise;
40
+ }
41
+ return Promise.resolve(`data:${accessToken}`);
42
+ },
43
+ });
44
+ const local = client.getQueryCache().build(client, {
45
+ queryKey: ['local'],
46
+ initialData: 'local-value',
47
+ meta: { persister: { storage: 'local' } },
48
+ });
49
+ const unsubscribe = observer.subscribe(() => undefined);
50
+ const query = observer.getCurrentQuery();
51
+ const previous = persistence.capture();
52
+ let notifications = 0;
53
+ const stop = persistence.subscribe(token => {
54
+ notifications++;
55
+ assert.equal(persistence.current(token), true);
56
+ assert.equal(persistence.current(previous), false);
57
+ assert.equal(aborted, true);
58
+ assert.equal(query.state.data, undefined);
59
+ assert.equal(query.state.error, null);
60
+ assert.equal(query.state.dataUpdatedAt, 0);
61
+ assert.equal(query.state.fetchStatus, 'idle');
62
+ assert.equal(query.state.status, 'pending');
63
+ });
64
+ try {
65
+ assert.deepEqual(requests, ['token-1']);
66
+ accessToken = undefined;
67
+ persistence.update(
68
+ { instanceId: null, siteId: 'admin', userId: null },
69
+ { refetchActive: false },
70
+ );
71
+ assert.equal(notifications, 1);
72
+ assert.equal(persistence.epoch, previous.epoch + 1);
73
+ assert.deepEqual(persistence.capture().scope, [null, 'admin', null]);
74
+ assert.equal(observer.getCurrentQuery(), query);
75
+ assert.equal(query.getObserversCount(), 1);
76
+ assert.equal(local.state.data, 'local-value');
77
+ await tick();
78
+ assert.deepEqual(requests, ['token-1']);
79
+ outgoing.resolve('obsolete-private');
80
+ await tick();
81
+ assert.equal(query.state.data, undefined);
82
+ assert.equal(query.state.error, null);
83
+ stop();
84
+
85
+ // Ordinary login must use its already-installed token and must not inherit
86
+ // the preceding outgoing transition's no-refetch policy.
87
+ accessToken = 'token-3';
88
+ persistence.update({ instanceId: 4, siteId: 'admin', userId: 3 });
89
+ assert.equal(query.state.data, undefined);
90
+ await tick();
91
+ assert.deepEqual(requests, ['token-1', 'token-3']);
92
+ assert.equal(query.state.data, 'data:token-3');
93
+
94
+ accessToken = 'token-5';
95
+ persistence.update({ instanceId: 4, siteId: 'admin', userId: 5 }, { refetchActive: true });
96
+ await tick();
97
+ assert.deepEqual(requests, ['token-1', 'token-3', 'token-5']);
98
+ assert.equal(query.state.data, 'data:token-5');
99
+ } finally {
100
+ stop();
101
+ unsubscribe();
102
+ outgoing.resolve('obsolete-private');
103
+ client.clear();
104
+ }
105
+ });
106
+
107
+ test('ServiceStorage forwards outgoing refetch policy unchanged', () => {
108
+ const source = readFileSync(new URL('../../src/service/storage.ts', import.meta.url), 'utf8')
109
+ .replace(/^import[\s\S]*?;\n/gm, '')
110
+ .replace(/^@Service[^\n]*\n/gm, '');
111
+ const javascript = stripTypeScriptTypes(source).replace(
112
+ 'export class ServiceStorage',
113
+ 'class ServiceStorage',
114
+ );
115
+ const ServiceStorage = new Function('BeanBase', `${javascript}\nreturn ServiceStorage;`)(
116
+ class BeanBase {},
117
+ );
118
+ const storage = new ServiceStorage();
119
+ const calls: any[] = [];
120
+ storage._persistence = { update: (...args: any[]) => calls.push(args) };
121
+ const identity = { instanceId: null, siteId: 'admin', userId: null };
122
+ const options = { refetchActive: false };
123
+ storage.update(identity, options);
124
+ assert.equal(calls.length, 1);
125
+ assert.equal(calls[0][0], identity);
126
+ assert.equal(calls[0][1], options);
127
+ storage.update({ instanceId: 4, siteId: 'admin', userId: 3 });
128
+ assert.equal(calls[1][1], undefined);
129
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-openapi",
3
- "version": "5.1.55",
3
+ "version": "5.1.56",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "",
6
6
  "keywords": [
@@ -8,6 +8,7 @@ import type { ModelSdk } from './model/sdk.js';
8
8
 
9
9
  export class Monkey extends BeanSimple implements IMonkeyBeanInit, IMonkeyAppInitialize {
10
10
  private _modelSdk: Ref<ModelSdk | undefined> = ref();
11
+ private _sdkGeneration = 0;
11
12
 
12
13
  async beanInit(bean: BeanContainer, beanInstance: BeanBase) {
13
14
  const self = this;
@@ -36,10 +37,11 @@ export class Monkey extends BeanSimple implements IMonkeyBeanInit, IMonkeyAppIni
36
37
  }
37
38
 
38
39
  private async _loadSdk() {
39
- this._modelSdk.value = await this.app.bean._getBeanSelector(
40
- 'a-openapi.model.sdk',
41
- true,
42
- this.app.meta.locale.current,
43
- );
40
+ const generation = ++this._sdkGeneration;
41
+ const locale = this.app.meta.locale.current;
42
+ const sdk = await this.app.bean._getBeanSelector('a-openapi.model.sdk', true, locale);
43
+ if (generation === this._sdkGeneration && locale === this.app.meta.locale.current) {
44
+ this._modelSdk.value = sdk;
45
+ }
44
46
  }
45
47
  }
@@ -35,6 +35,7 @@ export interface ISchemaObjectExtensionFieldRest extends ITableColumnOptions {
35
35
  'visible'?: boolean;
36
36
  'disableNotifyChanged'?: boolean;
37
37
  'readonly'?: boolean;
38
+ 'draft'?: boolean;
38
39
  'onEffect'?: TypeFormFieldOnEffect;
39
40
  //
40
41
  'render'?: TypeFormFieldRenderComponentNormal | TypeTableCellRenderComponentNormal;
@@ -70,6 +71,7 @@ export type TypeSchemaScene = 'table' | TypeFormSchemaScene;
70
71
  export type TypeFormSchemaScene = 'form' | 'form-view' | 'form-create' | 'filter';
71
72
 
72
73
  export const renderFormFieldTopPropsSystem = [
74
+ 'draft',
73
75
  'order',
74
76
  'table',
75
77
  'form',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-router",
3
- "version": "5.1.41",
3
+ "version": "5.1.42",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "router",
6
6
  "keywords": [
@@ -9,10 +9,12 @@ import type {
9
9
  import { BeanBase, Use } from 'zova';
10
10
  import { Bean } from 'zova-module-a-bean';
11
11
 
12
+ import { dispatchRouteDiscard } from '../lib/routeDiscard.js';
12
13
  import { RouterGuardDisposers } from '../lib/routerGuardDisposers.js';
13
14
  import { BeanRouterViewBase } from '../lib/routerViewBase.js';
14
15
  import { ModelPageData } from '../model/pageData.js';
15
16
  import { IPageMeta } from '../types/pageMeta.js';
17
+ import { IRouteDiscardItem, TypeRouteDiscardReason } from '../types/routeDiscard.js';
16
18
  import { TypeErrorListener } from '../types/router.js';
17
19
  import { SysRouter } from './sys.router.js';
18
20
 
@@ -124,6 +126,18 @@ export class BeanRouter extends BeanBase {
124
126
  }
125
127
  }
126
128
 
129
+ notifyRouteDiscard(
130
+ reason: TypeRouteDiscardReason,
131
+ items: readonly IRouteDiscardItem[],
132
+ ownerSelector?: string,
133
+ ): Promise<void> {
134
+ return dispatchRouteDiscard(
135
+ { router: this, reason, items, ownerSelector },
136
+ event => this.app.meta.event.emitSync('a-router:routeDiscard', event),
137
+ () => console.error('Route discard cleanup failed'),
138
+ );
139
+ }
140
+
127
141
  afterEachBackRoute(route: RouteLocationNormalizedLoadedGeneric) {
128
142
  for (const routerView of this._routerViews) {
129
143
  const res = routerView.backRoute(route);
@@ -0,0 +1,26 @@
1
+ import type { IRouteDiscardEvent } from '../types/routeDiscard.js';
2
+
3
+ export function dispatchRouteDiscard(
4
+ event: Omit<IRouteDiscardEvent, 'waitUntil'>,
5
+ emit: (event: IRouteDiscardEvent) => void,
6
+ onError: (error: unknown) => void,
7
+ ): Promise<void> {
8
+ const cleanups: Promise<void>[] = [];
9
+ const items = event.items.map(item => ({ ...item }));
10
+ try {
11
+ emit({
12
+ ...event,
13
+ items,
14
+ waitUntil: cleanup => {
15
+ cleanups.push(cleanup);
16
+ },
17
+ });
18
+ } catch (error) {
19
+ onError(error);
20
+ }
21
+ return Promise.allSettled(cleanups).then(results => {
22
+ for (const result of results) {
23
+ if (result.status === 'rejected') onError(result.reason);
24
+ }
25
+ });
26
+ }
@@ -86,10 +86,13 @@ export class ServiceRouterGuards extends BeanRouterGuardsBase {
86
86
  info: NavigationInformation | undefined,
87
87
  ) {
88
88
  if (!info) return;
89
- const needBack =
90
- (info.type === NavigationType.pop && info.direction === NavigationDirection.back) ||
91
- (info.type === NavigationType.push && info.replace);
89
+ const isBack = info.type === NavigationType.pop && info.direction === NavigationDirection.back;
90
+ const needBack = isBack || (info.type === NavigationType.push && info.replace);
92
91
  if (!needBack) return;
92
+ if (isBack) {
93
+ // afterEach is synchronous; listeners register and invalidate outgoing work immediately.
94
+ void router.notifyRouteDiscard('back', [{ fullPath: from.fullPath }]);
95
+ }
93
96
  router.afterEachBackRoute(from);
94
97
  }
95
98
 
@@ -1,5 +1,6 @@
1
1
  export * from './pageHost.js';
2
2
  export * from './pageMeta.js';
3
+ export * from './routeDiscard.js';
3
4
  export * from './router.js';
4
5
  export * from './routerView.js';
5
6
  export * from './utils.js';
@@ -0,0 +1,24 @@
1
+ import type { BeanRouter } from '../bean/bean.router.js';
2
+
3
+ export type TypeRouteDiscardReason = 'back' | 'taskClose' | 'workspaceClose';
4
+
5
+ export interface IRouteDiscardItem {
6
+ readonly fullPath: string;
7
+ readonly componentKey?: string;
8
+ readonly tabKey?: string;
9
+ }
10
+
11
+ export interface IRouteDiscardEvent {
12
+ readonly router: BeanRouter;
13
+ readonly reason: TypeRouteDiscardReason;
14
+ readonly items: readonly IRouteDiscardItem[];
15
+ readonly ownerSelector?: string;
16
+ // Register cleanup synchronously so outgoing writers can be invalidated before navigation.
17
+ readonly waitUntil: (cleanup: Promise<void>) => void;
18
+ }
19
+
20
+ declare module 'zova' {
21
+ export interface IEventRecord {
22
+ 'a-router:routeDiscard': { data: IRouteDiscardEvent; result: void };
23
+ }
24
+ }
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import type { BeanRouter } from '../../src/bean/bean.router.js';
5
+ import type { IRouteDiscardEvent } from '../../src/types/routeDiscard.js';
6
+
7
+ import { dispatchRouteDiscard } from '../../src/lib/routeDiscard.js';
8
+
9
+ const router = {} as BeanRouter;
10
+
11
+ test('route discard invalidates synchronously and awaits registered cleanup', async () => {
12
+ const calls: string[] = [];
13
+ let finish!: () => void;
14
+ const cleanup = new Promise<void>(resolve => {
15
+ finish = resolve;
16
+ });
17
+ const completion = dispatchRouteDiscard(
18
+ { router, reason: 'back', items: [{ fullPath: '/entry/create' }] },
19
+ event => {
20
+ calls.push('invalidate');
21
+ event.waitUntil(
22
+ cleanup.then(() => {
23
+ calls.push('cleanup');
24
+ }),
25
+ );
26
+ },
27
+ () => assert.fail('unexpected cleanup error'),
28
+ ).then(() => {
29
+ calls.push('complete');
30
+ });
31
+
32
+ assert.deepEqual(calls, ['invalidate']);
33
+ await Promise.resolve();
34
+ assert.deepEqual(calls, ['invalidate']);
35
+ finish();
36
+ await completion;
37
+ assert.deepEqual(calls, ['invalidate', 'cleanup', 'complete']);
38
+ });
39
+
40
+ test('route discard captures detached inactive workspace identities', async () => {
41
+ const items = [
42
+ { fullPath: '/entry/1/edit', componentKey: 'edit-1', tabKey: 'workspace' },
43
+ { fullPath: '/entry/2/edit', componentKey: 'edit-2', tabKey: 'workspace' },
44
+ ];
45
+ let captured!: IRouteDiscardEvent;
46
+ await dispatchRouteDiscard(
47
+ { router, reason: 'workspaceClose', items, ownerSelector: 'admin' },
48
+ event => {
49
+ captured = event;
50
+ },
51
+ () => assert.fail('unexpected cleanup error'),
52
+ );
53
+ items[0].fullPath = '/newly-active';
54
+ items.pop();
55
+
56
+ assert.equal(captured.router, router);
57
+ assert.equal(captured.ownerSelector, 'admin');
58
+ assert.equal(captured.reason, 'workspaceClose');
59
+ assert.deepEqual(captured.items, [
60
+ { fullPath: '/entry/1/edit', componentKey: 'edit-1', tabKey: 'workspace' },
61
+ { fullPath: '/entry/2/edit', componentKey: 'edit-2', tabKey: 'workspace' },
62
+ ]);
63
+ });
64
+
65
+ test('failed cleanup is reported without skipping other cleanup or rejecting navigation', async () => {
66
+ const error = new Error('unavailable');
67
+ const reported: unknown[] = [];
68
+ let completed = false;
69
+ await dispatchRouteDiscard(
70
+ { router, reason: 'taskClose', items: [{ fullPath: '/entry/create' }] },
71
+ event => {
72
+ event.waitUntil(Promise.reject(error));
73
+ event.waitUntil(
74
+ Promise.resolve().then(() => {
75
+ completed = true;
76
+ }),
77
+ );
78
+ },
79
+ failure => reported.push(failure),
80
+ );
81
+
82
+ assert.equal(completed, true);
83
+ assert.deepEqual(reported, [error]);
84
+ });
85
+
86
+ test('a synchronous listener failure still drains registered cleanup', async () => {
87
+ const error = new Error('listener failed');
88
+ const reported: unknown[] = [];
89
+ let completed = false;
90
+ await dispatchRouteDiscard(
91
+ { router, reason: 'back', items: [{ fullPath: '/entry/create' }] },
92
+ event => {
93
+ event.waitUntil(
94
+ Promise.resolve().then(() => {
95
+ completed = true;
96
+ }),
97
+ );
98
+ throw error;
99
+ },
100
+ failure => reported.push(failure),
101
+ );
102
+
103
+ assert.equal(completed, true);
104
+ assert.deepEqual(reported, [error]);
105
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-module-a-routertabs",
3
- "version": "5.1.34",
3
+ "version": "5.1.35",
4
4
  "gitHead": "09d901d17140a80ee0764211b441cda72fd94663",
5
5
  "description": "routertabs",
6
6
  "keywords": [