Skip to content

Commit 54358f0

Browse files
feat(app/router): add registerDownstreamRoutes extension hook for downstream route injection (#4242)
Closes #4241. Add a `registerDownstreamRoutes(options)` named export that downstream Vue projects can call before `getRouter()` is invoked to inject their own routes without patching the shared devkit file. Three extension points: - `coreModules` — unconditionally mounted (spread into coreRoutes) - `adminChildModules` — added to adminChildModules before injectAdminChildren - `optionalModules` — added to optionalModules, gated by isModuleActive Route composition is moved inside `getRouter()` so that registry mutations made during module initialisation are always visible at composition time. Unit tests added (6 cases): no-registration baseline, coreModules/optionalModules injection, adminChildModules no-throw, ordering (stack-first), activation gating.
1 parent 10c3f17 commit 54358f0

2 files changed

Lines changed: 194 additions & 42 deletions

File tree

src/modules/app/app.router.js

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,60 +18,95 @@ import tasks from '../tasks/router/tasks.router';
1818
import billing, { organizationRoutes as billingOrganizationRoutes } from '../billing/router/billing.router';
1919
import legal from '../legal/router/legal.router';
2020

21-
// Core modules — always mounted
22-
const coreRoutes = [].concat(home, auth, users);
23-
2421
/**
25-
* Admin child modules — routes injected as children of the `/admin` parent
26-
* route via `injectAdminChildren`. Downstream projects should register any
27-
* module that contributes an admin tab here (see MIGRATIONS.md).
28-
*
29-
* Each module's router file should export routes with **relative** paths
30-
* (e.g. `'my-tab'` rather than `'/admin/my-tab'`) so they resolve under
31-
* the `/admin/` parent.
22+
* Downstream route registries — mutated by `registerDownstreamRoutes` before
23+
* the router is instantiated. Module-load order guarantees downstream code
24+
* (imported before this file's composition runs) populates these arrays first.
3225
*
33-
* @example
34-
* import myTabRoutes from '../my-tab/router/my-tab.router';
35-
* const adminChildModules = [
36-
* { name: 'my-tab', routes: myTabRoutes },
37-
* ];
26+
* @type {Array}
3827
*/
39-
const adminChildModules = [];
40-
injectAdminChildren(admin, adminChildModules, isModuleActive);
28+
const _downstreamCoreModules = [];
29+
const _downstreamAdminChildModules = [];
30+
const _downstreamOptionalModules = [];
4131

4232
/**
43-
* Organization-settings child modules — routes injected as children of the
44-
* `/users/organizations/:organizationId` parent route via `injectModuleChildren`.
45-
* Base devkit ships this empty; PR (c) and downstream projects populate it
46-
* (e.g. a billing-settings tab rendered inside the org detail layout).
33+
* Register downstream-specific route extensions.
34+
*
35+
* Call this from your downstream module (e.g. `src/modules/<project>/index.js`)
36+
* BEFORE the router is instantiated. Calling it mutates the internal registry
37+
* arrays in place; the router composition picks up the additions automatically.
4738
*
48-
* Each module's router file should export routes with **relative** paths
49-
* (e.g. `'billing'` rather than `'/users/organizations/:organizationId/billing'`)
50-
* so they resolve under the org parent.
39+
* @param {object} [options={}]
40+
* @param {Array} [options.coreModules] Routes spread into `coreRoutes` (always mounted, no activation gate).
41+
* @param {Array} [options.adminChildModules] Added to `adminChildModules` (injected under `/admin`).
42+
* @param {Array} [options.optionalModules] Added to `optionalModules` (gated by `isModuleActive`).
43+
* @returns {void}
5144
*/
52-
const organizationChildModules = [
53-
{ name: 'billing', routes: billingOrganizationRoutes },
54-
];
55-
injectModuleChildren(organizations, organizationChildModules, isModuleActive, ORG_PARENT_PATH);
56-
57-
// Optional modules — mounted only when activated
58-
const optionalModules = [
59-
{ name: 'organizations', routes: organizations },
60-
{ name: 'admin', routes: admin },
61-
{ name: 'tasks', routes: tasks },
62-
{ name: 'billing', routes: billing },
63-
{ name: 'legal', routes: legal },
64-
];
65-
66-
const routes = optionalModules.reduce(
67-
(acc, mod) => (isModuleActive(mod.name) ? acc.concat(mod.routes) : acc),
68-
coreRoutes,
69-
);
45+
export function registerDownstreamRoutes(options = {}) {
46+
if (options.coreModules) _downstreamCoreModules.push(...options.coreModules);
47+
if (options.adminChildModules) _downstreamAdminChildModules.push(...options.adminChildModules);
48+
if (options.optionalModules) _downstreamOptionalModules.push(...options.optionalModules);
49+
}
7050

7151
/**
7252
* Router configuration.
53+
*
54+
* Route composition is deferred inside `getRouter()` so that
55+
* `registerDownstreamRoutes` calls made during module initialisation (before
56+
* `getRouter` is invoked from `main.js`) are always visible to the composition.
7357
*/
7458
const getRouter = () => {
59+
// Core modules — always mounted
60+
const coreRoutes = [].concat(home, auth, users, ..._downstreamCoreModules);
61+
62+
/**
63+
* Admin child modules — routes injected as children of the `/admin` parent
64+
* route via `injectAdminChildren`. Downstream projects should register any
65+
* module that contributes an admin tab here (see MIGRATIONS.md).
66+
*
67+
* Each module's router file should export routes with **relative** paths
68+
* (e.g. `'my-tab'` rather than `'/admin/my-tab'`) so they resolve under
69+
* the `/admin/` parent.
70+
*
71+
* @example
72+
* import myTabRoutes from '../my-tab/router/my-tab.router';
73+
* const adminChildModules = [
74+
* { name: 'my-tab', routes: myTabRoutes },
75+
* ];
76+
*/
77+
const adminChildModules = [..._downstreamAdminChildModules];
78+
injectAdminChildren(admin, adminChildModules, isModuleActive);
79+
80+
/**
81+
* Organization-settings child modules — routes injected as children of the
82+
* `/users/organizations/:organizationId` parent route via `injectModuleChildren`.
83+
* Base devkit ships this empty; PR (c) and downstream projects populate it
84+
* (e.g. a billing-settings tab rendered inside the org detail layout).
85+
*
86+
* Each module's router file should export routes with **relative** paths
87+
* (e.g. `'billing'` rather than `'/users/organizations/:organizationId/billing'`)
88+
* so they resolve under the org parent.
89+
*/
90+
const organizationChildModules = [
91+
{ name: 'billing', routes: billingOrganizationRoutes },
92+
];
93+
injectModuleChildren(organizations, organizationChildModules, isModuleActive, ORG_PARENT_PATH);
94+
95+
// Optional modules — mounted only when activated
96+
const optionalModules = [
97+
{ name: 'organizations', routes: organizations },
98+
{ name: 'admin', routes: admin },
99+
{ name: 'tasks', routes: tasks },
100+
{ name: 'billing', routes: billing },
101+
{ name: 'legal', routes: legal },
102+
..._downstreamOptionalModules,
103+
];
104+
105+
const routes = optionalModules.reduce(
106+
(acc, mod) => (isModuleActive(mod.name) ? acc.concat(mod.routes) : acc),
107+
coreRoutes,
108+
);
109+
75110
const router = createRouter({
76111
history: createWebHistory(import.meta.env.BASE_URL),
77112
routes,

src/modules/app/tests/app.router.unit.tests.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,120 @@ describe('app.router', () => {
560560
});
561561
});
562562
});
563+
564+
describe('registerDownstreamRoutes', () => {
565+
/**
566+
* Each test resets modules so it gets a fresh registry (arrays start empty).
567+
* registerDownstreamRoutes is imported from the same fresh module instance
568+
* as getRouter, so mutations are visible to the composition.
569+
*/
570+
async function setupFreshModule(registerFn) {
571+
vi.resetModules();
572+
vi.doMock('../../auth/stores/auth.store', () => ({
573+
useAuthStore: () => mockAuthStore,
574+
}));
575+
vi.doMock('../../../lib/services/config', () => ({
576+
default: testConfig,
577+
}));
578+
vi.doMock('../../../lib/helpers/ability.js', () => ({
579+
ability: mockAbility,
580+
}));
581+
vi.doMock('../../billing/stores/billing.store', () => ({
582+
useBillingStore: () => mockBillingStore,
583+
}));
584+
vi.doMock('../../../lib/helpers/analytics', () => ({
585+
capturePageview: (...args) => mockCapturePageview(...args),
586+
}));
587+
vi.doMock('../../../lib/helpers/modules', () => ({
588+
isModuleActive: (...args) => mockIsModuleActive(...args),
589+
}));
590+
const mod = await import('../app.router.js');
591+
if (registerFn) registerFn(mod.registerDownstreamRoutes);
592+
return mod;
593+
}
594+
595+
beforeEach(() => {
596+
mockIsModuleActive = () => true;
597+
mockAuthStore.isLoggedIn = false;
598+
mockAuthStore.serverConfig = null;
599+
mockAuthStore.user = null;
600+
});
601+
602+
it('(a) routes work unchanged when registerDownstreamRoutes is never called', async () => {
603+
const mod = await setupFreshModule(null);
604+
const router = mod.default();
605+
const paths = router.options.routes.map((r) => r.path);
606+
// Stack-defined routes must still exist
607+
expect(paths).toContain('/');
608+
expect(paths).toContain('/signin');
609+
expect(paths).toContain('/tasks');
610+
expect(paths).toContain('/admin');
611+
});
612+
613+
it('(b) calling registerDownstreamRoutes({ optionalModules }) adds the route to the compiled list', async () => {
614+
const fakeRoute = { path: '/downstream-feature', name: 'DownstreamFeature', component: { template: '<div />' } };
615+
const mod = await setupFreshModule((register) => {
616+
register({ optionalModules: [{ name: 'downstream-feature', routes: [fakeRoute] }] });
617+
});
618+
const router = mod.default();
619+
const paths = router.options.routes.map((r) => r.path);
620+
expect(paths).toContain('/downstream-feature');
621+
});
622+
623+
it('(b) calling registerDownstreamRoutes({ coreModules }) adds core route (no activation gate)', async () => {
624+
const fakeRoute = { path: '/ds-core', name: 'DsCore', component: { template: '<div />' } };
625+
const mod = await setupFreshModule((register) => {
626+
register({ coreModules: [fakeRoute] });
627+
});
628+
const router = mod.default();
629+
const paths = router.options.routes.map((r) => r.path);
630+
expect(paths).toContain('/ds-core');
631+
});
632+
633+
it('(b) registerDownstreamRoutes({ adminChildModules }) populates adminChildModules before injectAdminChildren runs', async () => {
634+
// We verify indirectly: the downstream admin module entry must not throw and
635+
// the router must still create without error.
636+
const fakeAdminChild = { path: 'ds-admin-tab', name: 'DsAdminTab', component: { template: '<div />' } };
637+
let caughtError = null;
638+
try {
639+
const mod = await setupFreshModule((register) => {
640+
register({ adminChildModules: [{ name: 'ds-admin-tab', routes: [fakeAdminChild] }] });
641+
});
642+
mod.default(); // must not throw
643+
} catch (err) {
644+
caughtError = err;
645+
}
646+
expect(caughtError).toBeNull();
647+
});
648+
649+
it('(c) stack routes appear before downstream routes in the compiled list', async () => {
650+
const fakeRoute = { path: '/downstream-last', name: 'DownstreamLast', component: { template: '<div />' } };
651+
const mod = await setupFreshModule((register) => {
652+
register({ optionalModules: [{ name: 'downstream-last', routes: [fakeRoute] }] });
653+
});
654+
const router = mod.default();
655+
const paths = router.options.routes.map((r) => r.path);
656+
const homeIdx = paths.indexOf('/');
657+
const dsIdx = paths.indexOf('/downstream-last');
658+
// Stack home route must appear BEFORE the downstream-injected route
659+
expect(homeIdx).toBeGreaterThanOrEqual(0);
660+
expect(dsIdx).toBeGreaterThan(homeIdx);
661+
});
662+
663+
it('(c) downstream optional module is gated by isModuleActive', async () => {
664+
const fakeRoute = { path: '/gated-ds', name: 'GatedDs', component: { template: '<div />' } };
665+
// Deactivate the downstream module
666+
mockIsModuleActive = (name) => name !== 'gated-ds';
667+
const mod = await setupFreshModule((register) => {
668+
register({ optionalModules: [{ name: 'gated-ds', routes: [fakeRoute] }] });
669+
});
670+
const router = mod.default();
671+
const paths = router.options.routes.map((r) => r.path);
672+
expect(paths).not.toContain('/gated-ds');
673+
});
674+
675+
it('registerDownstreamRoutes is a named export of app.router.js', async () => {
676+
const mod = await setupFreshModule(null);
677+
expect(typeof mod.registerDownstreamRoutes).toBe('function');
678+
});
679+
});

0 commit comments

Comments
 (0)