From 485a7038e96f378a7acda4e465b527ee0388f2ab Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Thu, 16 Jul 2026 15:54:30 +0200 Subject: [PATCH] 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 Signed-off-by: Matt Blenkinsop Signed-off-by: Lucas Gass --- .../prog/js/vue/modules/islands-intranet.ts | 90 +++++++++++++++++ .../prog/js/vue/modules/islands-opac.ts | 29 ++++++ .../prog/js/vue/modules/islands.ts | 99 +++++-------------- rspack.config.js | 6 +- 4 files changed, 149 insertions(+), 75 deletions(-) create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-opac.ts diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts new file mode 100644 index 0000000000..30f53fdaf2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-intranet.ts @@ -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} + */ +const islands: 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: [], + }, + }, + ], +]); + +/** + * 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(); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-opac.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-opac.ts new file mode 100644 index 0000000000..780b6e65d3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands-opac.ts @@ -0,0 +1,29 @@ +export * from "./islands"; +import { hydrate, registerIsland, WebComponentDynamicImport } from "./islands"; + +/** + * The islands available in the OPAC. + * @type {Map} + */ +const islands: Map = 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(); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts index 64a16a5cea..f971bf8ee4 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts @@ -2,9 +2,6 @@ import { Component, defineCustomElement } from "vue"; export * from "vue"; import { createPinia } from "pinia"; 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. @@ -18,8 +15,21 @@ export type WebComponentDynamicImport = { config?: Record<"stores", Array>; }; +/** + * Pinia store definitions available to islands, keyed by the name components + * reference in their config.stores list. + * @typedef {Object.} IslandStoreDefinitions + */ +export type IslandStoreDefinitions = Record< + string, + (pinia: ReturnType) => unknown +>; + /** * 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} * @property {string} key - The name of the component. * @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 = - 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: {}, - }, - ], - ]); + new Map(); /** * 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. + * @param {IslandStoreDefinitions} [storeDefinitions] - The stores islands on this page may request via config.stores. * @returns {void} */ -export function hydrate(): void { +export function hydrate(storeDefinitions: IslandStoreDefinitions = {}): void { window.requestIdleCallback(async () => { + if (componentRegistry.size === 0) { + return; + } + const pinia = createPinia(); - const storesMatrix = { - mainStore: useMainStore(pinia), - navigationStore: useNavigationStore(pinia), - vendorStore: useVendorStore(pinia), - }; + const storesMatrix = Object.fromEntries( + Object.entries(storeDefinitions).map(([name, useStore]) => [ + name, + useStore(pinia), + ]) + ); const islandTagNames = Array.from(componentRegistry.keys()).join(", "); const requestedIslands = new Set( @@ -203,7 +160,3 @@ export function hydrate(): void { }); }); } - -if (parseInt(document?.currentScript?.getAttribute("init") ?? "0", 10)) { - hydrate(); -} diff --git a/rspack.config.js b/rspack.config.js index 422bff86f5..3e2bf49212 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -16,6 +16,7 @@ const islandsExport = application => { return { resolve: { + extensions: [".ts", "..."], alias: { "@fetch": path.resolve( __dirname, @@ -33,7 +34,7 @@ const islandsExport = application => { entry: { islands: [ "./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: { @@ -101,6 +102,7 @@ const islandsExport = application => { module.exports = [ { resolve: { + extensions: [".ts", "..."], alias: { "@fetch": path.resolve( __dirname, @@ -132,7 +134,7 @@ module.exports = [ ], islands: [ "./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: [ "./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",