Bug 43091: Stop emitting staff-only Vue islands into the OPAC dist

Both islands builds share modules/islands.ts, which hardcodes every
island. The OPAC compilation therefore emits staff-only lazy chunks
(acquisitions-menu, vendor-menu, admin-menu) into
koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/, and the OPAC islands.esm.js
bundles the staff Pinia stores.

- split modules/islands.ts into shared machinery and per-application
  entry points (islands-intranet.ts, islands-opac.ts)
- declare each application's islands as a Map literal and feed it
  through registerIsland(), the same path plugin islands take
- pass store definitions into hydrate() instead of importing every
  store in the shared module
- keep the full plugin import surface (registerIsland, hydrate, Vue
  re-exports) on both entries via star re-exports
- point the rspack islands entries at the per-application modules

Test plan:
1. yarn build
2. ls koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/
   => only islands.esm.js and a patron-self-renewal chunk are emitted
3. ls koha-tmpl/intranet-tmpl/prog/js/vue/dist/
   => acquisitions-menu, vendor-menu and admin-menu chunks are still
   present
4. In the staff interface, visit pages using the acquisitions, vendor
   and admin menu islands and verify they render
5. Enable self-renewal for a patron's category and put the patron
   inside the availability window, e.g.:
   UPDATE categories SET self_renewal_enabled = 1
     WHERE categorycode =
     (SELECT categorycode FROM borrowers WHERE userid = 'koha');
   UPDATE borrowers
     SET dateexpiry = DATE_ADD(CURDATE(), INTERVAL 7 DAY),
     debarred = NULL WHERE userid = 'koha';
6. Log into the OPAC as that patron and open 'your summary'
   => the patron-self-renewal chunk loads from
   /opac-tmpl/bootstrap/js/vue/dist/ and the self-renewal modal
   completes successfully

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Matt Blenkinsop <matt.blenkinsop@openfifth.co.uk>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Paul Derscheid 2026-07-16 15:54:30 +02:00 committed by Pedro Amorim
parent be1bc44511
commit 8ebc1d0f2b
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
4 changed files with 149 additions and 75 deletions

View File

@ -0,0 +1,90 @@
export * from "./islands";
import {
hydrate as hydrateIslands,
registerIsland,
IslandStoreDefinitions,
WebComponentDynamicImport,
} from "./islands";
import { useMainStore } from "../stores/main";
import { useNavigationStore } from "../stores/navigation";
import { useVendorStore } from "../stores/vendors";
/**
* The islands available in the staff interface.
* @type {Map<string, WebComponentDynamicImport>}
*/
const islands: Map<string, WebComponentDynamicImport> = new Map([
[
"acquisitions-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "acquisitions-menu" */
"../components/Islands/AcquisitionsMenu.vue"
);
return module.default;
},
config: {
stores: ["vendorStore", "navigationStore"],
},
},
],
[
"vendor-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "vendor-menu" */
"../components/Islands/VendorMenu.vue"
);
return module.default;
},
config: {
stores: ["vendorStore", "navigationStore"],
},
},
],
[
"admin-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "admin-menu" */
"../components/Islands/AdminMenu.vue"
);
return module.default;
},
config: {
stores: [],
},
},
],
]);
/**
* The Pinia stores islands in the staff interface may request via config.stores.
* @type {IslandStoreDefinitions}
*/
const storeDefinitions: IslandStoreDefinitions = {
mainStore: useMainStore,
navigationStore: useNavigationStore,
vendorStore: useVendorStore,
};
// Core islands take the same registration path as plugin islands.
islands.forEach((entry, name) => registerIsland(name, entry));
/**
* Hydrates islands with the staff interface store set.
*
* This local export takes precedence over the star re-export from ./islands,
* so imports of hydrate from the built islands.esm.js get the stores baked in.
* @returns {void}
*/
export function hydrate(): void {
hydrateIslands(storeDefinitions);
}
if (parseInt(document?.currentScript?.getAttribute("init") ?? "0", 10)) {
hydrate();
}

View File

@ -0,0 +1,29 @@
export * from "./islands";
import { hydrate, registerIsland, WebComponentDynamicImport } from "./islands";
/**
* The islands available in the OPAC.
* @type {Map<string, WebComponentDynamicImport>}
*/
const islands: Map<string, WebComponentDynamicImport> = new Map([
[
"patron-self-renewal",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "patron-self-renewal" */
"../components/Islands/PatronSelfRenewal/PatronSelfRenewal.vue"
);
return module.default;
},
config: {},
},
],
]);
// Core islands take the same registration path as plugin islands.
islands.forEach((entry, name) => registerIsland(name, entry));
if (parseInt(document?.currentScript?.getAttribute("init") ?? "0", 10)) {
hydrate();
}

View File

@ -2,9 +2,6 @@ import { Component, defineCustomElement } from "vue";
export * from "vue"; export * from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import { $__ } from "../i18n"; import { $__ } from "../i18n";
import { useMainStore } from "../stores/main";
import { useNavigationStore } from "../stores/navigation";
import { useVendorStore } from "../stores/vendors";
/** /**
* Represents a web component with an import function and optional configuration. * Represents a web component with an import function and optional configuration.
@ -18,8 +15,21 @@ export type WebComponentDynamicImport = {
config?: Record<"stores", Array<string>>; config?: Record<"stores", Array<string>>;
}; };
/**
* Pinia store definitions available to islands, keyed by the name components
* reference in their config.stores list.
* @typedef {Object.<string, function(Pinia): Object>} IslandStoreDefinitions
*/
export type IslandStoreDefinitions = Record<
string,
(pinia: ReturnType<typeof createPinia>) => unknown
>;
/** /**
* A registry for Vue components. * A registry for Vue components.
*
* Starts empty; the per-application entry points (islands-intranet.ts,
* islands-opac.ts) and Koha plugins populate it via registerIsland().
* @type {Map<string, WebComponentDynamicImport>} * @type {Map<string, WebComponentDynamicImport>}
* @property {string} key - The name of the component. * @property {string} key - The name of the component.
* @property {WebComponentDynamicImport} value - The configuration for the component. Includes the import function and optional configuration. * @property {WebComponentDynamicImport} value - The configuration for the component. Includes the import function and optional configuration.
@ -42,66 +52,7 @@ export type WebComponentDynamicImport = {
* ], * ],
*/ */
export const componentRegistry: Map<string, WebComponentDynamicImport> = export const componentRegistry: Map<string, WebComponentDynamicImport> =
new Map([ new Map();
[
"acquisitions-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "acquisitions-menu" */
"../components/Islands/AcquisitionsMenu.vue"
);
return module.default;
},
config: {
stores: ["vendorStore", "navigationStore"],
},
},
],
[
"vendor-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "vendor-menu" */
"../components/Islands/VendorMenu.vue"
);
return module.default;
},
config: {
stores: ["vendorStore", "navigationStore"],
},
},
],
[
"admin-menu",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "admin-menu" */
"../components/Islands/AdminMenu.vue"
);
return module.default;
},
config: {
stores: [],
},
},
],
[
"patron-self-renewal",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "patron-self-renewal" */
"../components/Islands/PatronSelfRenewal/PatronSelfRenewal.vue"
);
return module.default;
},
config: {},
},
],
]);
/** /**
* Registers an island component for hydration. * Registers an island component for hydration.
@ -143,16 +94,22 @@ export function registerIsland(
/** /**
* Hydrates custom elements by scanning the document and loading only necessary components. * Hydrates custom elements by scanning the document and loading only necessary components.
* @param {IslandStoreDefinitions} [storeDefinitions] - The stores islands on this page may request via config.stores.
* @returns {void} * @returns {void}
*/ */
export function hydrate(): void { export function hydrate(storeDefinitions: IslandStoreDefinitions = {}): void {
window.requestIdleCallback(async () => { window.requestIdleCallback(async () => {
if (componentRegistry.size === 0) {
return;
}
const pinia = createPinia(); const pinia = createPinia();
const storesMatrix = { const storesMatrix = Object.fromEntries(
mainStore: useMainStore(pinia), Object.entries(storeDefinitions).map(([name, useStore]) => [
navigationStore: useNavigationStore(pinia), name,
vendorStore: useVendorStore(pinia), useStore(pinia),
}; ])
);
const islandTagNames = Array.from(componentRegistry.keys()).join(", "); const islandTagNames = Array.from(componentRegistry.keys()).join(", ");
const requestedIslands = new Set( const requestedIslands = new Set(
@ -203,7 +160,3 @@ export function hydrate(): void {
}); });
}); });
} }
if (parseInt(document?.currentScript?.getAttribute("init") ?? "0", 10)) {
hydrate();
}

View File

@ -11,6 +11,7 @@ const islandsExport = application => {
return { return {
resolve: { resolve: {
extensions: [".ts", "..."],
alias: { alias: {
"@fetch": path.resolve( "@fetch": path.resolve(
__dirname, __dirname,
@ -28,7 +29,7 @@ const islandsExport = application => {
entry: { entry: {
islands: [ islands: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts", `./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-${application}.ts`,
], ],
}, },
output: { output: {
@ -96,6 +97,7 @@ const islandsExport = application => {
module.exports = [ module.exports = [
{ {
resolve: { resolve: {
extensions: [".ts", "..."],
alias: { alias: {
"@fetch": path.resolve( "@fetch": path.resolve(
__dirname, __dirname,
@ -127,7 +129,7 @@ module.exports = [
], ],
islands: [ islands: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts", "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts",
], ],
sip2: [ sip2: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js", "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",