Skip to content

Commit a2437b5

Browse files
fix(objectql): a package registered again lists its navigation contributions once, not once more per registration (#30656)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgQztMGCF63EykeFKi27sXAd)_ domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL ## 维护者速读 - 改了什么:包体注册时,它往别的应用里加的导航项(`navigationContributions`)改为按包整体替换,不再每注册一次就追加一份;卸载包时一并清掉。 - 为什么改:HotCRM 停用再启用模块后,所有模块的菜单都出现两次,重启才好。实测停用/启用本身并不重复,翻倍来自包体被重新注册(`os dev` 重编产物触发的重载会把产物里每个包都重注册一次),每次重载多一份,正好是「一个模块操作,所有模块都重复」。 - 风险与回滚:只影响导航项的登记方式;替换在原位进行,顺序与首次启动完全一致;逐条追加的旧方法 `registerAppNavContribution` 不变。回滚即还原本 PR。 - 先看:`packages/objectql/src/registry.ts` 的 `setPackageNavContributions`;`packages/objectql/src/engine.ts` 导航折叠那段(约 L2402);`registry-nav-contributions.test.ts` 末尾新增的 #30581 describe。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/registry-nav-contributions.test.ts`,预期 13 个全绿,其中 #30581 下 7 个。 - 需要你做的:无,席位落地。 Fixes #30581 Clause-②: no — the served app navigation returns to what a fresh boot serves; nothing a caller sends is refused Metadata-protocol: none ## What changes **The card's mechanism, measured and corrected.** On the real kernel (`MetadataPlugin` + `ObjectQLPlugin` + `AppPlugin`, `PATCH /packages/:id/disable|enable` through `HttpDispatcher`, `origin/main` 6701c08; the 18.3.0 enable door is the same code), an app plus two modules (`orders` into `g_sales`, `service` into `g_service`): | step | served `GET /meta/app` navigation | registered contributions | |---|---|---| | boot | `g_sales[accounts,orders] g_service[cases]` | 2 | | disable/enable `orders`, twice | unchanged after each enable | 2 | | one `metadata:reloaded` with the artifact | `g_sales[accounts,orders,orders] g_service[cases,cases]` | 4 | | the suite registered again through the `manifest` door | three copies of each | 6 | So disable and enable do not re-fold anything. What doubles every module's entries is a re-registration of the package bodies: `ObjectQLPlugin.refreshReloadedPackages` re-registers every package of the artifact on `metadata:reloaded` (fired by the `os dev` artifact watcher and the HMR reload POST), and the manifest step of `ObjectQL.registerApp` called `registerAppNavContribution`, which only appends. A restart starts from one copy again. HotCRM's measurement most likely saw a reload land between the disable and the enable. **The fix: derive, don't append.** - `SchemaRegistry.setPackageNavContributions(packageId, contributions)` (new) makes the given list the package's whole set of contributions, in every app. It replaces in place: an app's list keeps the package's entries where the first registration put them, so ties in the fold (equal priority, the common case at the default 200) resolve in fresh-boot order. An app the package newly targets gets them at the end; an app it no longer names loses them. - `ObjectQL.registerApp` calls it for every body with an id, with an empty list when the body declares none, so a body that dropped its contributions clears them. A body with no id keeps the old additive call. - `uninstallPackage` clears the package's contributions. Before, an uninstalled package's entries stayed in the app: with no package row left, the fold's disabled check could not withhold them. - `registerAppNavContribution` is unchanged (additive, one contribution at a time). - The registry test double (`registry-module-mock.ts`) answers the new member. The fold itself (`applyNavContributions`) is untouched, and so are `enablePackage` / `disablePackage` and the `onPackageEnabled` announcement (#30377): no dedupe by label or path after the fact. ## Tests New top-level describes in the existing harness files. `packages/objectql/src/registry-nav-contributions.test.ts`, through `ObjectQL.registerApp` (app + two contributing modules + one module with none, equal priorities), each compared with a fresh registration's snapshot taken in the same test: - every body registered twice more (two reloads): fresh order, 4 contributions; - one module registered twice more: its entries stay in place; - two disable/enable cycles of a module, each with a reload: disabled hides only that module's entries, enabled again equals fresh; the `onPackageEnabled` listener hears the module exactly once per enable; - a module with no contributions disabled, re-registered and enabled: navigation unchanged; - a re-registered body that changed its contributions: the dropped entry leaves, the new one appears once; a body with none clears them; - uninstalling a module (not disabling it) drops its contributions. `packages/runtime/src/domains/packages-disable-app-module.test.ts`, through the real doors: two cycles of `PATCH …/disable`, `metadata:reloaded` with the artifact, `PATCH …/enable`, then the suite registered again through the `manifest` service; the served `GET /meta/app/qs_app` group equals the fresh boot's after each step. <details><summary>Ablations (one per negative pin, objectql rebuilt each leg, marker proved in dist/)</summary> Each through `scripts/ablation-replace.mjs`, `node scripts/ablation-dist-preflight.mjs objectql ABLATION_30581` green before the run, restored to the HEAD blob afterwards. 1. `engine.ts`: the body path back to the additive call. objectql: 4 failed (two reloads, one module re-registered, disable/enable with reload, changed body); runtime: the #30581 case failed, `enabled again #1: expected [ 'nav_accounts', 'nav_orders', …(1) ] to deeply equal [ 'nav_accounts', 'nav_orders' ]`. 2. `registry.ts`: replace at the end instead of in place. objectql: 2 failed (one module re-registered, changed body); runtime stays green (one contributor there). 3. `registry.ts`: the uninstall clear removed. objectql: 1 failed (uninstall). Restore leg: objectql rebuilt, `ablation-dist-preflight.mjs objectql ABLATION_30581 --absent` green, working tree clean against HEAD. </details> ## Verification All on the final head `0be4e9a0b` (branch merged with `origin/main` 14c67d4), dogfood closure rebuilt first. - `pnpm --filter @objectstack/objectql test`: Test Files 555 passed (555), Tests 11057 passed (11057); `typecheck` exit 0. - `pnpm --filter @objectstack/runtime test`: Test Files 544 passed (544), Tests 8702 passed, 19 skipped (8721); `typecheck` exit 0. - Full dogfood under the verify lock (`pnpm --filter @objectstack/dogfood test`): VERDICT command-exit 0, Test Files 420 passed, 3 skipped (423), Tests 3504 passed, 36 skipped (3540). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 63 families, each run with its exit code recorded before any pipe, all 63 exit 0; `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - `eslint --no-inline-config` on the five changed source and test files: exit 0 (the repo-wide `pnpm lint` is left to CI's `Lint & Repo Gates`). `check-nul-bytes`: exit 0. ## Acceptance notes - The card reads "re-enable re-folds the contributions". Measured, it does not: the enable and disable doors only flip the row, and the fold already asks the row on every read (#28117). The duplicate is the reload's re-registration, which this PR makes idempotent. The card's ask, "each entry appears exactly once after any number of disable and enable cycles", holds with or without reloads in between, and is pinned with two cycles. - `registerAppNavContribution` keeps appending. Today only tests call it; a plugin that calls it from code twice still gets two copies, as before. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b5765be commit a2437b5

6 files changed

Lines changed: 260 additions & 3 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a package registered again lists its navigation contributions once, not once more per registration
6+
7+
Clause-②: no — the served app navigation returns to what a fresh boot serves; nothing a caller sends is refused
8+
Metadata-protocol: none
9+
10+
- **Before.** Registering a package body appended its `navigationContributions` to the target app's list every time. An artifact reload (`metadata:reloaded`, fired when `os dev` recompiles the artifact or on the HMR reload POST) re-registers every package of the artifact, so after one reload the sidebar listed every module's contributed entries twice, three times after the next, until the server restarted. An uninstalled package's contributions also stayed in the app.
11+
- **Now.** A package body's contributions replace that package's previous set (`SchemaRegistry.setPackageNavContributions`, new), in the place its first registration put them, so the navigation after any number of reloads, re-registrations and disable/enable cycles equals a fresh boot's, in the same order. Uninstalling a package drops its contributions. `registerAppNavContribution` keeps its additive meaning.

‎packages/objectql/src/engine.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2407,13 +2407,24 @@ export class ObjectQL implements IObjectQLEngine {
24072407
// plugin adding its menu into the `setup` app). Merged into the target
24082408
// app's navigation on read by group id + priority, so registration order
24092409
// does not matter.
2410-
if (Array.isArray((manifest as any).navigationContributions) && (manifest as any).navigationContributions.length > 0) {
2411-
for (const contribution of (manifest as any).navigationContributions) {
2410+
// [#30581] A body with an id REPLACES its package's contributions, so a
2411+
// re-registration of the same body (an artifact reload re-registers every
2412+
// package of the artifact) folds each entry once, not once more per
2413+
// registration; a body that now declares none clears an earlier one's.
2414+
const navigationContributions: any[] = Array.isArray((manifest as any).navigationContributions)
2415+
? (manifest as any).navigationContributions
2416+
: [];
2417+
if (id) {
2418+
this._registry.setPackageNavContributions(id, navigationContributions);
2419+
} else {
2420+
for (const contribution of navigationContributions) {
24122421
this._registry.registerAppNavContribution(contribution, id);
24132422
}
2423+
}
2424+
if (navigationContributions.length > 0) {
24142425
this.logger.debug('Registered navigation contributions', {
24152426
from: id,
2416-
count: (manifest as any).navigationContributions.length,
2427+
count: navigationContributions.length,
24172428
});
24182429
}
24192430

‎packages/objectql/src/registry-module-mock.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ export function createRegistryModuleMock(
192192
// enable owes. The double never enables a disabled package, so it never
193193
// announces either.
194194
onPackageEnabled: vi.fn(() => () => {}),
195+
// [#30581] Every `registerApp` of a body with an id hands its package's
196+
// navigation contributions to this member (replace, never append). The
197+
// double folds no navigation, so it records the call and nothing else.
198+
setPackageNavContributions: vi.fn(),
195199
reset: vi.fn(),
196200
metadata: { get: vi.fn(() => new Map()) },
197201
...instanceOverrides,

‎packages/objectql/src/registry-nav-contributions.test.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { SchemaRegistry } from './registry';
5+
import { ObjectQL } from './engine';
56

67
/**
78
* ADR-0029 D7 — app navigation contributions.
@@ -104,3 +105,131 @@ describe('SchemaRegistry navigation contributions (ADR-0029 D7)', () => {
104105
expect(group.children).toHaveLength(1);
105106
});
106107
});
108+
109+
/**
110+
* [#30581] A package BODY's contributions are derived from its manifest, never
111+
* appended to: registering the same body again folds each entry once.
112+
*
113+
* An artifact reload (`metadata:reloaded`) re-registers every package of the
114+
* artifact through `ObjectQL.registerApp`, and the manifest step used to push
115+
* each contribution once more per registration, so a single reload listed
116+
* every module's entries twice until a restart. Each pin below compares the
117+
* served app against the snapshot a fresh registration of the same bodies
118+
* gives, in the same test.
119+
*/
120+
describe('[#30581] a package body re-registered folds its contributions once', () => {
121+
const APP = 'com.qa.nav.app';
122+
const ORDERS = 'com.qa.nav.orders';
123+
const SERVICE = 'com.qa.nav.service';
124+
const LOOSE = 'com.qa.nav.loose';
125+
126+
const appBody = () => ({
127+
id: APP, name: APP, version: '1.0.0', type: 'app', namespace: 'qn',
128+
apps: [{
129+
name: 'qn_app', label: 'Suite',
130+
navigation: [
131+
{ id: 'g_sales', type: 'group', label: 'Sales', children: [{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'qn_account' }] },
132+
{ id: 'g_service', type: 'group', label: 'Service', children: [] },
133+
],
134+
}],
135+
});
136+
// Equal priorities on purpose: the default 200 makes ties the common case,
137+
// so the order a re-registration leaves is a real question.
138+
const ordersBody = (items = [{ id: 'nav_orders', type: 'object', label: 'Orders', objectName: 'qn_order' }]) => ({
139+
id: ORDERS, name: ORDERS, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' },
140+
navigationContributions: [
141+
{ app: 'qn_app', group: 'g_sales', items },
142+
{ app: 'qn_app', group: 'g_service', items: [{ id: 'nav_returns', type: 'object', label: 'Returns', objectName: 'qn_return' }] },
143+
],
144+
});
145+
const serviceBody = () => ({
146+
id: SERVICE, name: SERVICE, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' },
147+
navigationContributions: [
148+
{ app: 'qn_app', group: 'g_service', items: [{ id: 'nav_cases', type: 'object', label: 'Cases', objectName: 'qn_case' }] },
149+
{ app: 'qn_app', group: 'g_sales', items: [{ id: 'nav_quotes', type: 'object', label: 'Quotes', objectName: 'qn_quote' }] },
150+
],
151+
});
152+
// A module that contributes nothing: toggling or re-registering it moves nothing.
153+
const looseBody = () => ({ id: LOOSE, name: LOOSE, version: '1.0.0', type: 'module', namespace: 'qn', dependencies: { [APP]: '^1.0.0' } });
154+
155+
/** Every navigation entry of the served app, `group/id`, in served order. */
156+
const served = (engine: ObjectQL): string[] =>
157+
(engine.registry.getApp('qn_app')?.navigation ?? []).flatMap((n: any) =>
158+
n.type === 'group' ? (n.children ?? []).map((c: any) => `${n.id}/${c.id}`) : [n.id]);
159+
160+
const boot = (): ObjectQL => {
161+
const engine = new ObjectQL();
162+
(engine.registry as any).logLevel = 'silent';
163+
for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any);
164+
return engine;
165+
};
166+
167+
let engine: ObjectQL;
168+
let fresh: string[];
169+
beforeEach(() => {
170+
engine = boot();
171+
fresh = served(boot());
172+
});
173+
174+
it('serves each entry once on a fresh registration (the control)', () => {
175+
expect(fresh).toEqual([
176+
'g_sales/nav_accounts', 'g_sales/nav_orders', 'g_sales/nav_quotes',
177+
'g_service/nav_returns', 'g_service/nav_cases',
178+
]);
179+
expect(served(engine)).toEqual(fresh);
180+
});
181+
182+
it('re-registering every body twice (two artifact reloads) serves the fresh registration, in its order', () => {
183+
for (let reload = 0; reload < 2; reload++) {
184+
for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any);
185+
}
186+
expect(served(engine)).toEqual(fresh);
187+
expect(engine.registry.getAppNavContributions('qn_app')).toHaveLength(4);
188+
});
189+
190+
it('re-registering ONE module keeps its entries where a fresh registration puts them', () => {
191+
engine.registerApp(ordersBody() as any);
192+
engine.registerApp(ordersBody() as any);
193+
expect(served(engine)).toEqual(fresh);
194+
});
195+
196+
it('two disable/enable cycles of a module, each followed by a reload, serve the fresh registration', () => {
197+
// The enable announcement the roll-up recompute rides on (#30377) still
198+
// fires once per enable of a disabled package: a reload is no enable.
199+
const heard: string[] = [];
200+
const stop = engine.registry.onPackageEnabled((id) => { heard.push(id); });
201+
for (let cycle = 0; cycle < 2; cycle++) {
202+
engine.registry.disablePackage(ORDERS);
203+
expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']);
204+
for (const body of [appBody(), ordersBody(), serviceBody(), looseBody()]) engine.registerApp(body as any);
205+
engine.registry.enablePackage(ORDERS);
206+
expect(served(engine)).toEqual(fresh);
207+
}
208+
stop();
209+
expect(heard).toEqual([ORDERS, ORDERS]);
210+
});
211+
212+
it('toggling and re-registering a module with no contributions leaves the navigation unchanged', () => {
213+
engine.registry.disablePackage(LOOSE);
214+
engine.registerApp(looseBody() as any);
215+
engine.registry.enablePackage(LOOSE);
216+
expect(served(engine)).toEqual(fresh);
217+
});
218+
219+
it('a re-registered body replaces its contributions: an entry it dropped leaves, one it added appears once', () => {
220+
engine.registerApp(ordersBody([{ id: 'nav_invoices', type: 'object', label: 'Invoices', objectName: 'qn_invoice' }]) as any);
221+
expect(served(engine)).toEqual([
222+
'g_sales/nav_accounts', 'g_sales/nav_invoices', 'g_sales/nav_quotes',
223+
'g_service/nav_returns', 'g_service/nav_cases',
224+
]);
225+
const { navigationContributions: _dropped, ...withNone } = ordersBody();
226+
engine.registerApp(withNone as any);
227+
expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']);
228+
});
229+
230+
it('uninstalling a module (not disabling it) drops its contributions from the app', () => {
231+
expect(engine.registry.uninstallPackage(ORDERS), 'precondition: uninstalled').toBe(true);
232+
expect(served(engine)).toEqual(['g_sales/nav_accounts', 'g_sales/nav_quotes', 'g_service/nav_cases']);
233+
expect(engine.registry.getAppNavContributions('qn_app').map((c) => c.packageId)).toEqual([SERVICE, SERVICE]);
234+
});
235+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8456,6 +8456,9 @@ export class SchemaRegistry {
84568456
// …and the default record pages it declared, so the next package may
84578457
// declare a default for those objects.
84588458
this.forgetDefaultRecordPageClaims(id);
8459+
// [#30581] …and the navigation entries it put into other packages' apps:
8460+
// with no row left, the fold's disabled check cannot withhold them.
8461+
this.setPackageNavContributions(id, []);
84598462

84608463
// [#18877] The boot seed forgets the id together with the row. A package
84618464
// that no longer exists has no lifecycle state to preserve, so the next
@@ -8616,6 +8619,60 @@ export class SchemaRegistry {
86168619
);
86178620
}
86188621

8622+
/**
8623+
* [#30581] Make `contributions` the WHOLE set of navigation contributions
8624+
* `packageId` holds, in every app: what the package registered before is
8625+
* replaced, never added to. The door a package BODY takes (`registerApp`'s
8626+
* manifest step), so registering the same body again (an artifact reload,
8627+
* whose `metadata:reloaded` re-registers every package of the artifact, a
8628+
* manifest replay, an upgrade) leaves each entry once, instead of one more
8629+
* copy of every module's entries per registration until a restart.
8630+
*
8631+
* Replaced IN PLACE: an app's list keeps the package's entries where its
8632+
* first registration put them, so the fold's ties (equal priority, the
8633+
* common case at the default 200) resolve in the order a fresh boot gives.
8634+
* An app the package newly contributes to gets them at the end; an app it no
8635+
* longer names loses them. {@link registerAppNavContribution} stays the
8636+
* additive verb, for a caller adding one contribution at a time.
8637+
*/
8638+
setPackageNavContributions(
8639+
packageId: string,
8640+
contributions: ReadonlyArray<{ app: string; group?: string; priority?: number; items?: any[] }>,
8641+
): void {
8642+
const incoming = new Map<string, Array<{ packageId?: string; group?: string; priority: number; items: any[] }>>();
8643+
for (const contribution of contributions) {
8644+
if (!contribution || !contribution.app) continue;
8645+
const list = incoming.get(contribution.app) ?? [];
8646+
list.push({
8647+
packageId,
8648+
group: contribution.group,
8649+
priority: contribution.priority ?? 200,
8650+
items: Array.isArray(contribution.items) ? contribution.items : [],
8651+
});
8652+
incoming.set(contribution.app, list);
8653+
}
8654+
for (const [app, list] of [...this.appNavContributions]) {
8655+
const at = list.findIndex((c) => c.packageId === packageId);
8656+
if (at === -1) continue;
8657+
const kept = list.filter((c) => c.packageId !== packageId);
8658+
kept.splice(at, 0, ...(incoming.get(app) ?? []));
8659+
incoming.delete(app);
8660+
if (kept.length > 0) this.appNavContributions.set(app, kept);
8661+
else this.appNavContributions.delete(app);
8662+
}
8663+
for (const [app, list] of incoming) {
8664+
this.appNavContributions.set(app, [...(this.appNavContributions.get(app) ?? []), ...list]);
8665+
}
8666+
for (const c of contributions) {
8667+
if (!c || !c.app) continue;
8668+
this.log(
8669+
`[Registry] Navigation contribution: ${packageId} -> ${c.app}` +
8670+
(c.group ? `/${c.group}` : '') +
8671+
` (${Array.isArray(c.items) ? c.items.length : 0} items)`,
8672+
);
8673+
}
8674+
}
8675+
86198676
/** Contributions registered for an app (empty array when none). */
86208677
getAppNavContributions(appName: string): Array<{ packageId?: string; group?: string; priority: number; items: any[] }> {
86218678
return this.appNavContributions.get(appName) ?? [];

‎packages/runtime/src/domains/packages-disable-app-module.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ interface Booted {
149149
engine: ObjectQL;
150150
}
151151

152+
/** [#30581] The host plugin's context of the last boot, to fire the kernel event an artifact reload fires. */
153+
let lastHostContext: PluginContext | undefined;
154+
152155
/**
153156
* One kernel, the suite arriving by `path`:
154157
* - `code`: the suite is the `AppPlugin`'s bundle, registered through the
@@ -174,6 +177,7 @@ async function bootKernel(path: InstallPath, root: string): Promise<Booted> {
174177
name: 'qa.suite-host',
175178
dependencies: ['com.objectstack.engine.objectql'],
176179
init: async (ctx: PluginContext) => {
180+
lastHostContext = ctx;
177181
const door = ctx.getService<ManifestDoor>('manifest');
178182
if (path === 'marketplace') await door.register(suiteArtifact(), { source: 'marketplace' });
179183
for (const manifest of STANDALONE) await door.register(manifest);
@@ -369,6 +373,47 @@ describe('[#28117] a disabled module stays disabled across a restart (the enviro
369373
}, 120_000);
370374
});
371375

376+
describe('[#30581] a module disabled and enabled again, around artifact reloads, lists its nav entries once', () => {
377+
let root: string;
378+
let booted: Booted | undefined;
379+
let context: PluginContext;
380+
381+
beforeAll(async () => {
382+
root = mkdtempSync(join(tmpdir(), 'os-suite-reload-'));
383+
process.env.OS_HOME = mkdtempSync(join(home, 'reload-'));
384+
booted = await bootKernel('code', root);
385+
context = lastHostContext!;
386+
}, 60_000);
387+
afterAll(async () => {
388+
await shutdown(booted);
389+
rmSync(root, { recursive: true, force: true });
390+
}, 60_000);
391+
392+
// What `MetadataPlugin` fires when it reloads the artifact (`os dev` recompiling
393+
// `dist/objectstack.json`, the HMR POST): `ObjectQLPlugin` re-registers every
394+
// package of it through `registerApp`, the step that used to append each
395+
// contribution once more per reload.
396+
const reloadArtifact = () => context.trigger('metadata:reloaded', { changed: ['dist/objectstack.json'], metadata: suiteArtifact() });
397+
398+
it('serves the fresh boot\'s navigation after two disable/enable cycles, each with a reload, and after a manifest replay', async () => {
399+
const kernel = booted!.kernel;
400+
const fresh = await servedGroupIds(kernel);
401+
expect(fresh, 'precondition: the fresh boot lists each entry once').toEqual(['nav_accounts', 'nav_orders']);
402+
403+
for (let cycle = 1; cycle <= 2; cycle++) {
404+
expect((await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/disable`)).status, `disable #${cycle}`).toBe(200);
405+
await reloadArtifact();
406+
expect(await servedGroupIds(kernel), `disabled, after reload #${cycle}`).toEqual(['nav_accounts']);
407+
expect((await dispatchAs(kernel, 'PATCH', `/packages/${MODULE}/enable`)).status, `enable #${cycle}`).toBe(200);
408+
expect(await servedGroupIds(kernel), `enabled again #${cycle}`).toEqual(fresh);
409+
expect(registryGroupIds(booted!.engine)).toEqual(fresh);
410+
}
411+
412+
await kernel.getService<ManifestDoor>('manifest').register(suiteArtifact());
413+
expect(await servedGroupIds(kernel), 'after the suite is registered again through the manifest door').toEqual(fresh);
414+
});
415+
});
416+
372417
describe('[#28117 addendum 6523684730] what an app hook meets reading a disabled module\'s object through ctx.api.object()', () => {
373418
let root: string;
374419
let booted: Booted | undefined;

0 commit comments

Comments
 (0)