Bugzilla – Attachment 186551 Details for
Bug 36674
Lazy load api-client JS files
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 36674: Lazy-load intranet API clients via proxy
Bug-36674-Lazy-load-intranet-API-clients-via-proxy.patch (text/plain), 11.51 KB, created by
Paul Derscheid
on 2025-09-18 10:09:27 UTC
(
hide
)
Description:
Bug 36674: Lazy-load intranet API clients via proxy
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-09-18 10:09:27 UTC
Size:
11.51 KB
patch
obsolete
>From 39bf5389ad26c8a357e1e2cfc5607d7a9c6324c5 Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >Date: Wed, 17 Sep 2025 15:41:28 +0200 >Subject: [PATCH] Bug 36674: Lazy-load intranet API clients via proxy >MIME-Version: 1.0 >Content-Type: text/plain; charset=UTF-8 >Content-Transfer-Encoding: 8bit > >Replaced the eager imports in the intranet APIClient aggregator with >lazy proxies so individual client modules are only fetched the first >time they are actually used. > >Documented the pattern in the createClientProxy JSDoc, and kept the legacy >synchronous API intact by forwarding property access, method calls, and >promise chaining. The Vue aggregator remains on static imports for now >because the rspack bundles already would handle chunking (which we need to configure) >and would require broader consumer changes. > >This is a bit hacky, but only touches the api-client itself, which I like. >Otherwise we would have to adjust all consumers. > >Test plan: >1. Log in to the staff interface and visit Admin â System preferences. >2. Open DevTools, Network tab, and enable âDisable cacheâ. >3. Trigger an API call (e.g. edit a preference and click âSaveâ, or run >await APIClient.sysprefs.sysprefs.update_all({}).catch(()=>{}); from >the console). >4. Confirm a new request for js/fetch/system-preferences-api-client.js >appears and the page still behaves as before. >5. Theoretically we would need to test all call sites.. >6. Sign-off >--- > .../intranet-tmpl/prog/js/fetch/api-client.js | 230 +++++++++++++++--- > .../prog/js/vue/fetch/api-client.js | 2 + > 2 files changed, 204 insertions(+), 28 deletions(-) > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >index 2fe5a6dce8c..918947db605 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >@@ -1,33 +1,207 @@ > import HttpClient from "./http-client.js"; > >-import ArticleRequestAPIClient from "./article-request-api-client.js"; >-import AVAPIClient from "./authorised-values-api-client.js"; >-import CataloguingAPIClient from "./cataloguing-api-client.js"; >-import CirculationAPIClient from "./circulation-api-client.js"; >-import ClubAPIClient from "./club-api-client.js"; >-import CoverImageAPIClient from "./cover-image-api-client.js"; >-import LocalizationAPIClient from "./localization-api-client.js"; >-import PatronAPIClient from "./patron-api-client.js"; >-import PatronListAPIClient from "./patron-list-api-client.js"; >-import RecallAPIClient from "./recall-api-client.js"; >-import SysprefAPIClient from "./system-preferences-api-client.js"; >-import TicketAPIClient from "./ticket-api-client.js"; >-import AcquisitionAPIClient from "./acquisition-api-client.js"; >-import DefaultAPIClient from "./default-api-client.js"; >+/** >+ * @template {object} T >+ * @typedef {new (...args: unknown[]) => T} ClientConstructor >+ */ >+ >+/** >+ * @template {object} T >+ * @typedef {ClientConstructor<T> | { default: ClientConstructor<T> }} ClientModule >+ */ >+ >+/** >+ * Determines whether a value can safely be treated as an object for property >+ * access (including functions, which are callable objects in JS). >+ * >+ * @param {unknown} value >+ * @returns {value is object | Function} >+ */ >+const isObjectLike = value => >+ (typeof value === "object" && value !== null) || >+ typeof value === "function"; >+ >+/** >+ * Lazily instantiates an API client module the first time a consumer actually >+ * uses it. Callers still interact with `APIClient.foo` synchronously, but under >+ * the hood a proxy defers the dynamic `import()` until a method is invoked or a >+ * promise chain is attached. This keeps existing call sites unchanged while >+ * preventing every specialised client from being fetched on initial page load. >+ * >+ * @template {object} T >+ * @param {() => Promise<ClientModule<T> | ClientConstructor<T>>} loader dynamic importer for the client module >+ * @returns {T} proxy exposing the API client interface with lazy loading >+ */ >+const createClientProxy = loader => { >+ /** @type {Promise<T> | undefined} */ >+ let instancePromise; >+ >+ /** >+ * Extracts the client constructor from a dynamic import namespace. >+ * >+ * @param {ClientModule<T> | ClientConstructor<T>} namespace >+ * @returns {ClientConstructor<T>} >+ */ >+ const resolveClientConstructor = namespace => { >+ if (typeof namespace === "function") { >+ return /** @type {ClientConstructor<T>} */ (namespace); >+ } >+ >+ if (isObjectLike(namespace)) { >+ const maybeDefault = Reflect.get( >+ /** @type {object} */ (namespace), >+ "default" >+ ); >+ if (typeof maybeDefault === "function") { >+ return /** @type {ClientConstructor<T>} */ (maybeDefault); >+ } >+ } >+ throw new TypeError("API client module did not export a constructor"); >+ }; >+ >+ /** >+ * Resolves (or re-resolves after failure) the underlying client instance. >+ * >+ * @returns {Promise<T>} promise resolving to the concrete client >+ */ >+ const loadInstance = () => { >+ if (!instancePromise) { >+ instancePromise = loader() >+ .then(resolveClientConstructor) >+ .then(Client => new Client(HttpClient)) >+ .catch(error => { >+ instancePromise = undefined; >+ throw error; >+ }); >+ } >+ return instancePromise; >+ }; >+ >+ /** >+ * Creates a proxy layer that defers property access and function calls >+ * until the client instance is available while keeping the existing call >+ * structure intact (including promise chaining support). >+ * >+ * @param {(client: T) => unknown} accessor resolver for the current target >+ * @param {(client: T) => unknown} [parentAccessor=accessor] context resolver >+ * @returns {unknown} proxy forwarding operations to the resolved target >+ */ >+ const createProxy = (accessor, parentAccessor = accessor) => { >+ /** >+ * Forwards promise chaining when consumers treat the proxy like a promise. >+ * >+ * @param {(value: unknown) => unknown} onFulfilled >+ * @param {(reason: unknown) => unknown} [onRejected] >+ * @returns {Promise<unknown>} >+ */ >+ const handleThen = (onFulfilled, onRejected) => >+ loadInstance() >+ .then(client => accessor(client)) >+ .then(onFulfilled, onRejected); >+ >+ /** >+ * Propagates errors when consumers attach a catch handler to the proxy. >+ * >+ * @param {(reason: unknown) => unknown} onRejected >+ * @returns {Promise<unknown>} >+ */ >+ const handleCatch = onRejected => >+ loadInstance() >+ .then(client => accessor(client)) >+ .catch(onRejected); >+ >+ /** >+ * Executes finally handlers while preserving the resolved value chain. >+ * >+ * @param {() => unknown} onFinally >+ * @returns {Promise<unknown>} >+ */ >+ const handleFinally = onFinally => >+ loadInstance() >+ .then(client => accessor(client)) >+ .finally(onFinally); >+ >+ /** >+ * Returns a proxy that represents a nested property on the client. >+ * >+ * @param {PropertyKey} prop >+ * @returns {unknown} >+ */ >+ const forwardProperty = prop => >+ createProxy(client => { >+ const target = accessor(client); >+ if (!isObjectLike(target)) { >+ return undefined; >+ } >+ return Reflect.get(/** @type {object} */ (target), prop); >+ }, accessor); >+ >+ /** >+ * Invokes a method on the resolved client while keeping the original >+ * `this` binding semantics. >+ * >+ * @param {unknown} thisArg >+ * @param {unknown[]} argArray >+ * @returns {Promise<unknown>} >+ */ >+ const invokeTarget = (thisArg, argArray) => >+ loadInstance().then(client => { >+ const target = accessor(client); >+ if (typeof target !== "function") { >+ throw new TypeError("API client property is not callable"); >+ } >+ const context = parentAccessor >+ ? parentAccessor(client) >+ : (thisArg ?? undefined); >+ return target.apply(context, argArray); >+ }); >+ >+ return new Proxy(function () {}, { >+ get(_, prop) { >+ if (prop === "then") { >+ return handleThen; >+ } >+ if (prop === "catch") { >+ return handleCatch; >+ } >+ if (prop === "finally") { >+ return handleFinally; >+ } >+ >+ return forwardProperty(prop); >+ }, >+ apply(_, thisArg, args) { >+ return invokeTarget(thisArg, /** @type {unknown[]} */ (args)); >+ }, >+ }); >+ }; >+ >+ return /** @type {T} */ (createProxy(client => client)); >+}; > > export const APIClient = { >- article_request: new ArticleRequestAPIClient(HttpClient), >- authorised_values: new AVAPIClient(HttpClient), >- acquisition: new AcquisitionAPIClient(HttpClient), >- cataloguing: new CataloguingAPIClient(HttpClient), >- circulation: new CirculationAPIClient(HttpClient), >- club: new ClubAPIClient(HttpClient), >- cover_image: new CoverImageAPIClient(HttpClient), >- localization: new LocalizationAPIClient(HttpClient), >- patron: new PatronAPIClient(HttpClient), >- patron_list: new PatronListAPIClient(HttpClient), >- recall: new RecallAPIClient(HttpClient), >- sysprefs: new SysprefAPIClient(HttpClient), >- ticket: new TicketAPIClient(HttpClient), >- default: new DefaultAPIClient(HttpClient), >+ article_request: createClientProxy( >+ () => import("./article-request-api-client.js") >+ ), >+ authorised_values: createClientProxy( >+ () => import("./authorised-values-api-client.js") >+ ), >+ acquisition: createClientProxy(() => import("./acquisition-api-client.js")), >+ cataloguing: createClientProxy(() => import("./cataloguing-api-client.js")), >+ circulation: createClientProxy(() => import("./circulation-api-client.js")), >+ club: createClientProxy(() => import("./club-api-client.js")), >+ cover_image: createClientProxy(() => import("./cover-image-api-client.js")), >+ localization: createClientProxy( >+ () => import("./localization-api-client.js") >+ ), >+ patron: createClientProxy(() => import("./patron-api-client.js")), >+ patron_list: createClientProxy(() => import("./patron-list-api-client.js")), >+ recall: createClientProxy(() => import("./recall-api-client.js")), >+ sysprefs: createClientProxy( >+ () => import("./system-preferences-api-client.js") >+ ), >+ ticket: createClientProxy(() => import("./ticket-api-client.js")), >+ default: createClientProxy(() => import("./default-api-client.js")), > }; >+ >+export default APIClient; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >index c12f54beebf..d7b00857aaa 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >@@ -21,3 +21,5 @@ export const APIClient = { > preservation: new PreservationAPIClient(HttpClient), > record_sources: new RecordSourcesAPIClient(HttpClient), > }; >+ >+export default APIClient; >-- >2.39.5
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 36674
:
186551
|
186616
|
186617
|
186994
|
186995
|
187826