JS interceptors¶
Interceptors let one module change the behaviour of another module's JavaScript functions without editing its source. They are the JavaScript counterpart of Magento's PHP plugins: the same before, around and after hooks, ordered by sortOrder.
The build swaps the target module for a generated wrapper, so every importer of the target gets the intercepted version. The target file on disk is never modified.
import- Virtual module
\0interceptor:<path> beforearound- Original function
after
Declare an interceptor¶
A declaration has two parts: a config entry that says what to intercept, and an interceptor module that holds the hooks.
The config entry¶
Declare it under the interceptors key of the module.config.ts of the module that ships the interceptor (view/frontend/web/module.config.ts). The key is the interceptor's name:
export default {
interceptors: {
PromoPriceInterceptor: {
name: "PromoPriceInterceptor",
target: "Vendor_Catalog::js/price.js",
interceptor: "Vendor_Promo::js/interceptors/price.js",
sortOrder: 20,
},
},
};
| Field | Meaning |
|---|---|
name |
Unique id of the interceptor. It is the name used in warnings and errors. |
target |
The module to intercept, as Vendor_Module::path. Required: an entry without it is ignored. |
interceptor |
The module that exports the hooks, as Vendor_Module::path. |
sortOrder |
Execution order, lowest first. Defaults to 10. |
active |
Set it to false to switch the interceptor off. |
Both paths use the Vendor_Module::path form, so theme inheritance applies: if a theme overrides the target or the interceptor file, the override is the one the build uses.
If two module.config.ts files declare the same interceptor name, their entries are merged and the later one wins field by field. That is how a module or theme changes the sortOrder of, or deactivates (active: false), an interceptor it does not own.
The interceptor module¶
The interceptor module exports one function per hook. The export name is the hook type followed by the name of the target's export, with its first letter in capitals:
| Export | Hooks into | Signature |
|---|---|---|
beforeFormatPrice |
formatPrice |
(subject, ...args) |
aroundFormatPrice |
formatPrice |
(subject, proceed, ...args) |
afterFormatPrice |
formatPrice |
(subject, result, ...args) |
subject is the target module's exports. this is bound to the same object, but arrow functions cannot see this, so prefer subject.
beforemay return an array to replace the arguments the next hooks and the original function receive. The array holds the arguments only, withoutsubject. Any other return value leaves the arguments unchanged.arounddecides whether and how the original runs: callproceed(...args)to continue the chain, or return a value without calling it to short-circuit.afterreceives the result and must return the result to hand on, even if it did not change it.
To hook the target's default export, name the hook beforeDefault, aroundDefault or afterDefault.
Hooks run in this order: all before hooks, then the around hooks wrapped around the original function, then all after hooks. Within each type, the lowest sortOrder runs first. For around, that means the lowest sortOrder is the outermost wrapper.
A complete example¶
Vendor_Promo adjusts how Vendor_Catalog formats prices.
The target, in Vendor_Catalog:
export function formatPrice(amount, currency) {
return `${currency} ${amount.toFixed(2)}`;
}
The interceptor, in Vendor_Promo at view/frontend/web/js/interceptors/price.js:
export function beforeFormatPrice(subject, amount, currency) {
return [Math.round(amount * 100) / 100, currency];
}
export async function aroundFormatPrice(subject, proceed, amount, currency) {
if (amount === 0) {
return "Free";
}
return proceed(amount, currency);
}
export function afterFormatPrice(subject, result, amount, currency) {
return amount > 100 ? `${result} *` : result;
}
And the config entry shown above, in Vendor_Promo's module.config.ts.
With it in place, callers of formatPrice get:
| Call | Result |
|---|---|
formatPrice(10.005, "USD") |
"USD 10.01" |
formatPrice(0, "USD") |
"Free" |
formatPrice(250, "USD") |
"USD 250.00 *" |
This example was run against the engine's own test harness (generateInterceptors and the interceptor manager, with the same module layout) and produced the three results above.
Verify it in the build¶
The mage-obsidian:interceptors Vite plugin generates the interceptors when the build starts. It replaces every import of an intercepted target with a virtual module whose id is \0interceptor:<absolute path of the target>. The generated module imports the original file under the query ?originalIntercepted, imports your interceptor modules, registers each hook and re-exports every export of the target through a proxy.
Things to check when an interceptor does not seem to apply:
- Restart the build or the dev server after you change a declaration: the interceptors are generated once per theme, at build start.
- Read the build output. The engine prints
Target module not found for identifier: …orInterceptor module not found: …when aVendor_Module::pathdoes not resolve, andFailed to import target module …orFailed to import interceptor module …when Node cannot load the file. - A hook whose name does not match an export of the target stops the generation with
Interceptor <name> (…) exports '<hook>' but target … does not export '<method>'. The plugin reports that as[mage-obsidian:interceptors] Failed to generate interceptorsand the build carries on without any interceptor for that theme, so treat that line as a failure. [mage-obsidian:interceptors] themeName option is missingmeans the plugin was configured without a theme, and no interceptor is generated.
Limits¶
- Every exported function of an intercepted target becomes asynchronous, including the ones no hook touches. The generated wrapper runs the chain with
await, so a call returns aPromiseeven if the original is synchronous. Callers mustawaitit. - A target that exports a class or constructor cannot be intercepted. The proxy replaces it with a function, so
newfails. Move the function you want to intercept into its own module. - Only exported functions can be intercepted. If the target export is not a function, the engine warns and skips that hook. Other exports are passed through untouched.
- Anything that starts with
before,aroundorafteris read as a hook. Do not export helpers from an interceptor module with those prefixes. Other exports are ignored. - The target and the interceptor module can only use imports that Node resolves on its own. The engine imports both at build time to read their exports. A module that imports
Vendor_Module::path, a.vuecomponent, or a package not found from its folder is skipped, and only one console line is logged. Top-level code that needs the browser (window,document) can also make the import fail. - Exports are read once. The wrapper covers the exports the target has at build start; interception does not follow functions that the target adds or replaces at runtime.
- Hooks are per function, not per call site. Every importer of the target is affected, with no way to opt a single caller out.