MDL-87987 core: Add React profiler and dev-mode bundle switching

Add core/profiler and core/mount helpers that wrap React components in
<Profiler> when jsrev === -1. Extend import_map to serve .development.js
React bundles in dev mode via a path modifier on react and react-dom entries.

Co-authored-by: Andrew Nicols <[email protected]>
This commit is contained in:
meirzamoodle
2026-03-16 12:50:43 +08:00
committed by Adrian Greeve
co-authored by Andrew Nicols
parent 1347d848a0
commit a140c2ef7e
9 changed files with 389 additions and 26 deletions
@@ -102,8 +102,31 @@ class import_map implements \JsonSerializable {
$this->add_import('react', path: 'lib/js/bundles/react/react');
$this->add_import('react/', path: 'lib/js/bundles/react');
$this->add_import('react-dom', path: 'lib/js/bundles/react-dom/react-dom');
$this->add_import('react-dom/', path: 'lib/js/bundles/react-dom');
$this->add_import('scheduler', path: 'lib/js/bundles/scheduler/scheduler');
$this->add_import('react-dom/', path: 'lib/js/bundles/react-dom', modifier: $this->resolve_react_dev_path(...));
}
/**
* Modifier for React imports that resolves to the unminified development build when in developer mode.
*
* When the JS revision is -1 (developer mode / cachejs disabled), this substitutes the
* `.development.js` variant of a React bundle if it exists on disk, giving better stack
* traces and warnings during development.
*
* @param int $revision The JS revision number (-1 signals developer mode).
* @param string $requestedpath The bare specifier path that was requested.
* @param string $resolvedpath The resolved absolute filesystem path.
* @return string The (possibly substituted) absolute filesystem path to serve.
*/
protected function resolve_react_dev_path(int $revision, string $requestedpath, string $resolvedpath): string {
if ($revision === -1) {
// During development, resolve to the unminified version of React for better debugging.
$unminifiedpath = str_replace('.js', '.development.js', $resolvedpath);
if (file_exists($unminifiedpath)) {
return $unminifiedpath;
}
}
return $resolvedpath;
}
/**
@@ -116,6 +139,9 @@ class import_map implements \JsonSerializable {
* to locate the file on disk. Has no effect on the URL in the import map.
* @param bool $loadfromcomponent When true, the specifier is treated as a `<component>/<module>`
* prefix and resolved to the component's `js/esm/build/` directory. Used internally for `@moodle/lms/`.
* @param string $suffix File extension suffix appended when resolving filesystem paths (defaults to `.js`).
* @param callable|null $modifier Optional callable (int $revision, string $requestedpath, string $resolvedpath): string
* to transform the resolved filesystem path before the file is served. Not used for URL generation.
*/
public function add_import(
string $specifier,
@@ -123,12 +149,14 @@ class import_map implements \JsonSerializable {
?string $path = null,
bool $loadfromcomponent = false,
string $suffix = '.js',
?callable $modifier = null,
): void {
$this->imports[$specifier] = (object) [
'loader' => $loader,
'path' => $path,
'loadfromcomponent' => $loadfromcomponent,
'suffix' => $suffix,
'modifier' => $modifier,
];
$this->importssorted = false;
}
@@ -142,7 +170,10 @@ class import_map implements \JsonSerializable {
* @param string $requestedpath The bare specifier path (e.g. `react`, `@moodle/lms/mod_book/viewer`).
* @return string|null Absolute filesystem path to the JS file, or null if unresolved.
*/
public function get_path_for_script(string $requestedpath): ?string {
public function get_path_for_script(
int $revision,
string $requestedpath,
): ?string {
global $CFG;
// Sort longest-key-first once so a more-specific prefix always wins over a shorter one.
@@ -164,7 +195,11 @@ class import_map implements \JsonSerializable {
if ($importdata->loadfromcomponent) {
$subpath = substr($requestedpath, strlen($specifier));
return $this->resolve_module_identifier($importdata, $subpath);
$resolved = $this->resolve_module_identifier($importdata, $subpath);
if ($importdata->modifier !== null) {
$resolved = ($importdata->modifier)($revision, $requestedpath, $resolved);
}
return $resolved;
}
$pathremainder = substr($requestedpath, strlen($specifier));
@@ -173,7 +208,15 @@ class import_map implements \JsonSerializable {
if (in_array('..', explode('/', $pathremainder), true)) {
return null;
}
return implode(DIRECTORY_SEPARATOR, array_filter([$CFG->root, $importdata->path, $pathremainder])) . $importdata->suffix;
$resolved = implode(DIRECTORY_SEPARATOR, array_filter([
$CFG->root,
$importdata->path,
$pathremainder,
])) . $importdata->suffix;
if ($importdata->modifier !== null) {
$resolved = ($importdata->modifier)($revision, $requestedpath, $resolved);
}
return $resolved;
}
return null;
@@ -72,7 +72,7 @@ class esm_controller {
}
$importmap = \core\di::get(\core\output\requirements\import_map::class);
$fullpath = $importmap->get_path_for_script($scriptpath);
$fullpath = $importmap->get_path_for_script($revision, $scriptpath);
if ($fullpath !== null && file_exists($fullpath)) {
return $this->serve_script($request, $response, $revision, $fullpath, basename($fullpath));
}
+11
View File
@@ -0,0 +1,11 @@
import{createElement as p,Profiler as a}from"react";import{createRoot as c}from"react-dom/client";import{isProfilerEnabled as l,onRenderCallback as s}from"@moodle/lms/core/profiler";var o=new WeakMap;function E(e,t,i,u={}){let d=u.id||t.displayName||t.name||"ReactApp",n=p(t,i);l()&&(n=p(a,{id:d,onRender:s},n));let r=c(e);r.render(n);let m=()=>{r.unmount()};return o.set(e,m),m}function P(e){let t=o.get(e);t&&(t(),o.delete(e))}export{E as mountReactApp,P as unmountReactApp};
/**
* Shared React mount helper with optional profiling support.
*
* Use this for mounting React roots so profiling behavior is consistent
* across autoinit and manually-initialised entrypoints.
*
* @module core/mount
* @copyright Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+8
View File
@@ -0,0 +1,8 @@
import{createElement as d,Profiler as m}from"react";var t=()=>window.M?.cfg?.jsrev===-1,l=(o,r,e,n,i,s)=>{t()&&(window.console.groupCollapsed(`[${r}] ${o} - ${e.toFixed(2)}ms`),window.console.table({Component:o,Phase:r,"Duration (ms)":e.toFixed(2),"Base Duration (ms)":n.toFixed(2),"Start Time":i.toFixed(2),"Commit Time":s.toFixed(2)}),e>16&&window.console.warn(`Slow render: ${e.toFixed(2)}ms (target: <16ms for 60fps)`),e>50&&window.console.error(`Very slow render: ${e.toFixed(2)}ms - Consider optimization!`),window.console.groupEnd())},p=()=>t()?l:void 0;function w(o,r){if(!t())return o;let e=r||o.displayName||o.name||"Component",n=i=>d(m,{id:e,onRender:l},d(o,i));return n.displayName=`withProfiler(${e})`,n}export{p as getProfilerCallback,t as isProfilerEnabled,l as onRenderCallback,w as withProfiler};
/**
* Shared React Profiler helpers.
*
* @module core/profiler
* @copyright Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+1 -1
View File
@@ -1,4 +1,4 @@
import m from"react";import{createRoot as f}from"react-dom/client";var r="[data-react-component]",s="reactMounted",o="reactMounting",c=new WeakMap,E=()=>document.readyState==="loading"?new Promise(t=>document.addEventListener("DOMContentLoaded",t,{once:!0})):Promise.resolve(),w=t=>{let e=t.getAttribute("data-react-props");if(!e)return{};try{return JSON.parse(e)}catch(n){return window.console.error("[react_autoinit] invalid JSON",e,n),{}}},M=async t=>{if(!t)return null;if(!t.startsWith("@moodle/lms/"))return window.console.error("[react_autoinit] Invalid component format, expected @moodle/lms/<component>/<path>:",t),null;try{return await import(t)}catch(e){return window.console.error(`[react_autoinit] Failed to import: ${t}`,e),null}},d=async t=>{if(t.dataset[s]||t.dataset[o])return;t.dataset[o]="1";let e=t.getAttribute("data-react-component");if(!e){delete t.dataset[o];return}let n=await M(e);if(!n){window.console.warn("[react_autoinit] Component not found:",e),delete t.dataset[o];return}let i=n.default;if(!i){window.console.warn("[react_autoinit] Module has no default export:",e),delete t.dataset[o];return}try{let a=f(t);a.render(m.createElement(i,w(t))),c.set(t,()=>a.unmount()),t.dataset[s]="1"}catch(a){window.console.error("[react_autoinit] Mount failed:",e,a)}finally{delete t.dataset[o]}},l=t=>{let e=c.get(t);if(e){try{e()}catch(n){window.console.error("[react_autoinit] Error unmounting:",n)}c.delete(t)}delete t.dataset[s],delete t.dataset[o]},p=t=>{for(let e of t.querySelectorAll(r))d(e)},h=t=>{t instanceof Element&&(t.matches?.(r)&&d(t),t.querySelectorAll?.(r).forEach(d))},L=t=>{t instanceof Element&&(t.matches?.(r)&&l(t),t.querySelectorAll?.(r).forEach(l))},y=()=>{let t=new MutationObserver(e=>{e.forEach(n=>{n.addedNodes?.forEach(h),n.removedNodes?.forEach(L)})});return t.observe(document.documentElement,{childList:!0,subtree:!0}),t},u=null,v=async()=>{await E(),u||(u=y()),p(document)};v();
import{isProfilerEnabled as f}from"@moodle/lms/core/profiler";import{mountReactApp as w,unmountReactApp as p}from"@moodle/lms/core/mount";var i="[data-react-component]",s="reactMounted",o="reactMounting",d=new WeakMap,a=f(),E=()=>document.readyState==="loading"?new Promise(t=>document.addEventListener("DOMContentLoaded",t,{once:!0})):Promise.resolve(),M=t=>{let e=t.getAttribute("data-react-props");if(!e)return{};try{return JSON.parse(e)}catch(n){return window.console.error("[react_autoinit] invalid JSON",e,n),{}}},g=async t=>{if(!t)return null;if(!t.startsWith("@moodle/lms/"))return window.console.error("[react_autoinit] Invalid component format, expected @moodle/lms/<component>/<path>:",t),null;try{return a&&window.console.log(`[react_autoinit] Loading: ${t}`),await import(t)}catch(e){return window.console.error(`[react_autoinit] Failed to import: ${t}`,e),null}},h=(t,e,n)=>{let c=t.getAttribute("data-react-component")||"Unknown",r=w(t,e,n,{id:c});d.set(t,r)},l=async t=>{if(t.dataset[s]||t.dataset[o])return;t.dataset[o]="1";let e=t.getAttribute("data-react-component");if(!e){delete t.dataset[o];return}let n=await g(e);if(!n){window.console.warn("[react_autoinit] Component not found:",e),delete t.dataset[o];return}let c=n.default;if(!c){window.console.warn("[react_autoinit] Module has no default export:",e),delete t.dataset[o];return}try{let r=M(t);h(t,c,r),t.dataset[s]="1",a&&window.console.log(`[react_autoinit] Mounted via default: ${e}`)}catch(r){window.console.error("[react_autoinit] Mount failed:",e,r)}finally{delete t.dataset[o]}},u=t=>{let e=d.get(t)??(()=>p(t));if(e){try{if(e(),a){let n=t.getAttribute("data-react-component");window.console.log(`[react_autoinit] Unmounted: ${n}`)}}catch(n){window.console.error("[react_autoinit] Error unmounting:",n)}d.delete(t)}delete t.dataset[s],delete t.dataset[o]},y=t=>{let e=t.querySelectorAll(i);a&&e.length>0&&window.console.log(`[react_autoinit] Found ${e.length} component(s) to mount`);for(let n of e)l(n)},L=t=>{t instanceof Element&&(t.matches?.(i)&&(a&&window.console.log("[react_autoinit] New component detected"),l(t)),t.querySelectorAll?.(i).forEach(l))},v=t=>{t instanceof Element&&(t.matches?.(i)&&u(t),t.querySelectorAll?.(i).forEach(u))},_=()=>{let t=new MutationObserver(e=>{e.forEach(n=>{n.addedNodes?.forEach(L),n.removedNodes?.forEach(v)})});return t.observe(document.documentElement,{childList:!0,subtree:!0}),t},m=null,b=async()=>{await E(),a&&window.console.log("[react_autoinit] Initializing (profiling enabled)..."),m||(m=_(),a&&window.console.log("[react_autoinit] MutationObserver active")),y(document)};b();
/**
* Auto-init shim for Mustache React helper components.
*
+103
View File
@@ -0,0 +1,103 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Shared React mount helper with optional profiling support.
*
* Use this for mounting React roots so profiling behavior is consistent
* across autoinit and manually-initialised entrypoints.
*
* @module core/mount
* @copyright Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {createElement, Profiler} from "react";
import {createRoot} from "react-dom/client";
import type {ComponentType} from "react";
import {isProfilerEnabled, onRenderCallback} from "@moodle/lms/core/profiler";
/**
* Options for mounting a React component.
*/
type MountOptions = {
/** Identifier used as the React Profiler `id` when profiling is enabled. */
id?: string;
};
/** A function that unmounts a previously mounted React root. */
type UnmountFn = () => void;
/** Tracks the unmount function for each mounted container so callers can tear down roots cleanly. */
const rootUnmountMap = new WeakMap<Element, UnmountFn>();
/**
* Mounts a React component to a container with optional profiling support.
*
* When the Moodle dev-mode profiler is active (`M.cfg.jsrev === -1`), the
* component is automatically wrapped in a React `<Profiler>` so render timings
* appear in the browser console.
*
* @param container The DOM element that will become the React root.
* @param Component The React component to render.
* @param props Props to pass to the component.
* @param options Optional mount configuration.
* @returns A function that, when called, unmounts the React root from the container.
*/
export function mountReactApp<P extends object>(
container: Element,
Component: ComponentType<P>,
props: P,
options: MountOptions = {}
): () => void {
const componentId =
options.id || Component.displayName || Component.name || "ReactApp";
let node: any = createElement(Component, props);
if (isProfilerEnabled()) {
node = createElement(
Profiler,
{id: componentId, onRender: onRenderCallback},
node
);
}
const root = createRoot(container);
root.render(node);
const unmount = () => {
root.unmount();
};
rootUnmountMap.set(container, unmount);
return unmount;
}
/**
* Unmounts a previously mounted React app from a container.
*
* If the container was never mounted via {@link mountReactApp}, this is a no-op.
*
* @param container The DOM element whose React root should be unmounted.
*/
export function unmountReactApp(container: Element): void {
const unmount = rootUnmountMap.get(container);
if (unmount) {
unmount();
rootUnmountMap.delete(container);
}
}
+152
View File
@@ -0,0 +1,152 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Shared React Profiler helpers.
*
* @module core/profiler
* @copyright Meirza <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {createElement, Profiler} from "react";
import type {ComponentType, ProfilerOnRenderCallback} from "react";
/**
* Returns whether the React Profiler should be active.
*
* Profiling is enabled when Moodle is running in developer mode
* (`M.cfg.jsrev === -1`), which causes the profiling build of `react-dom`
* to be loaded instead of the standard production bundle.
*
* @returns `true` when developer mode is active and profiling is enabled.
*/
export const isProfilerEnabled = (): boolean => {
return (window as any).M?.cfg?.jsrev === -1;
};
/**
* React Profiler `onRender` callback that logs render timings to the console.
*
* Outputs a collapsed console group with a timing table for every render. Emits
* a `console.warn` for renders that exceed 16 ms (60 fps budget) and a
* `console.error` for renders that exceed 50 ms. Silently exits when profiling
* is disabled so it is safe to register unconditionally.
*
* @param id The `id` prop of the `<Profiler>` tree that just committed.
* @param phase `"mount"` on first render, `"update"` on subsequent renders.
* @param actualDuration Time spent rendering the profiled subtree (ms).
* @param baseDuration Estimated time to render without memoisation (ms).
* @param startTime When React began rendering this update (ms).
* @param commitTime When React committed this update (ms).
*/
export const onRenderCallback: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
if (!isProfilerEnabled()) {
return;
}
window.console.groupCollapsed(`[${phase}] ${id} - ${actualDuration.toFixed(2)}ms`);
window.console.table({
Component: id,
Phase: phase,
"Duration (ms)": actualDuration.toFixed(2),
"Base Duration (ms)": baseDuration.toFixed(2),
"Start Time": startTime.toFixed(2),
"Commit Time": commitTime.toFixed(2),
});
if (actualDuration > 16) {
window.console.warn(
`Slow render: ${actualDuration.toFixed(2)}ms (target: <16ms for 60fps)`
);
}
if (actualDuration > 50) {
window.console.error(
`Very slow render: ${actualDuration.toFixed(
2
)}ms - Consider optimization!`
);
}
window.console.groupEnd();
};
/**
* Returns the profiler `onRender` callback when profiling is enabled.
*
* Convenience helper for code paths that pass the callback directly to a
* `<Profiler>` prop — returns `undefined` in production so the prop can be
* spread without needing a separate conditional.
*
* @returns {@link onRenderCallback} when profiling is active, `undefined` otherwise.
*/
export const getProfilerCallback = (): ProfilerOnRenderCallback | undefined => {
return isProfilerEnabled() ? onRenderCallback : undefined;
};
/**
* Wraps a component with a React `<Profiler>` in developer mode.
*
* Returns the original component unchanged in production so there is no
* runtime overhead. The wrapped component's `displayName` is set to
* `withProfiler(<id>)` to make it identifiable in React DevTools.
*
* @example
* ```tsx
* import { withProfiler } from '@moodle/core/profiler';
*
* function MyComponent(props) {
* return <div>...</div>;
* }
*
* export default withProfiler(MyComponent, 'MyComponent');
* ```
*
* @param Component The React component to wrap.
* @param id Optional profiler ID. Falls back to `Component.displayName`,
* `Component.name`, or `"Component"` in that order.
* @returns The profiler-wrapped component in dev mode, or the original component in production.
*/
export function withProfiler<P extends object>(
Component: ComponentType<P>,
id?: string
): ComponentType<P> {
if (!isProfilerEnabled()) {
return Component;
}
const componentId =
id || Component.displayName || Component.name || "Component";
const ProfiledComponent = (props: P) =>
createElement(
Profiler,
{id: componentId, onRender: onRenderCallback},
createElement(Component, props)
);
ProfiledComponent.displayName = `withProfiler(${componentId})`;
return ProfiledComponent;
}
+64 -18
View File
@@ -34,18 +34,19 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import React from "react";
import {createRoot} from "react-dom/client";
import {isProfilerEnabled} from "@moodle/lms/core/profiler";
import {mountReactApp, unmountReactApp} from "@moodle/lms/core/mount";
const SELECTOR = "[data-react-component]";
const MOUNTED_FLAG = "reactMounted";
const MOUNTING_FLAG = "reactMounting";
const reactUnmountMap: WeakMap<Element, () => void> = new WeakMap();
const profilingEnabled = isProfilerEnabled();
/**
* DOM ready promise.
*
* @return {Promise<void>} Resolves when the DOM is ready.
* @returns Resolves when the DOM is ready.
*/
const domReady = () =>
document.readyState === "loading"
@@ -59,8 +60,8 @@ const domReady = () =>
/**
* Safe JSON parsing from data-react-props.
*
* @param {Element} el The element with the data-react-props attribute.
* @return {Record<string, any>} Parsed props object, or empty object on failure.
* @param el The element with the data-react-props attribute.
* @returns Parsed props object, or empty object on failure.
*/
const parseProps = (el: Element): Record<string, any> => {
const raw = el.getAttribute("data-react-props");
@@ -82,8 +83,8 @@ const parseProps = (el: Element): Record<string, any> => {
* resolved by the browser through the Moodle import map.
* The module must have a default-exported React function component.
*
* @param {string} componentName The component specifier in `@moodle/lms/<component>/<path>` format.
* @return {Promise<any>} The imported module, or null if resolution failed.
* @param componentName The component specifier in `@moodle/lms/<component>/<path>` format.
* @returns The imported module, or null if resolution failed.
*/
const resolveComponent = async(componentName: string): Promise<any> => {
if (!componentName) {
@@ -99,6 +100,11 @@ const resolveComponent = async(componentName: string): Promise<any> => {
}
try {
if (profilingEnabled) {
window.console.log(
`[react_autoinit] Loading: ${componentName}`
);
}
const module = await import(componentName);
return module;
} catch (e) {
@@ -107,10 +113,25 @@ const resolveComponent = async(componentName: string): Promise<any> => {
}
};
/**
* Mount a single React component with profiler support.
*/
const mountReactComponent = (
el: Element,
Component: any,
props: Record<string, any>
) => {
const componentName = el.getAttribute("data-react-component") || "Unknown";
const unmount = mountReactApp(el, Component, props, {
id: componentName,
});
reactUnmountMap.set(el, unmount);
};
/**
* Mount an element with the `data-react-component` attribute.
*
* @param {Element} el The element to mount.
* @param el The element to mount.
*/
const mountOne = async(el: Element) => {
if ((el as HTMLElement).dataset[MOUNTED_FLAG]) {
@@ -146,10 +167,15 @@ const mountOne = async(el: Element) => {
}
try {
const root = createRoot(el);
root.render(React.createElement(Component, parseProps(el)));
reactUnmountMap.set(el, () => root.unmount());
const props = parseProps(el);
mountReactComponent(el, Component, props);
(el as HTMLElement).dataset[MOUNTED_FLAG] = "1";
if (profilingEnabled) {
window.console.log(
`[react_autoinit] Mounted via default: ${componentName}`
);
}
} catch (e) {
window.console.error("[react_autoinit] Mount failed:", componentName, e);
} finally {
@@ -160,13 +186,17 @@ const mountOne = async(el: Element) => {
/**
* Unmount a single element.
*
* @param {Element} el The element to unmount.
* @param el The element to unmount.
*/
const unmountOne = (el: Element) => {
const unmount = reactUnmountMap.get(el);
const unmount = reactUnmountMap.get(el) ?? (() => unmountReactApp(el));
if (unmount) {
try {
unmount();
if (profilingEnabled) {
const componentName = el.getAttribute("data-react-component");
window.console.log(`[react_autoinit] Unmounted: ${componentName}`);
}
} catch (e) {
window.console.error("[react_autoinit] Error unmounting:", e);
}
@@ -179,10 +209,17 @@ const unmountOne = (el: Element) => {
/**
* Scan a root element and mount all matching React components within it.
*
* @param {Element|Document} root The root to scan.
* @param root The root to scan.
*/
const scanAndMount = (root: Element | Document) => {
for (const el of root.querySelectorAll(SELECTOR)) {
const elements = root.querySelectorAll(SELECTOR);
if (profilingEnabled && elements.length > 0) {
window.console.log(
`[react_autoinit] Found ${elements.length} component(s) to mount`
);
}
for (const el of elements) {
mountOne(el);
}
};
@@ -190,7 +227,7 @@ const scanAndMount = (root: Element | Document) => {
/**
* Handle an added DOM node, mounting any React components within it.
*
* @param {Node} node The added node to handle.
* @param node The added node to handle.
*/
const handleAddedNode = (node: Node) => {
if (!(node instanceof Element)) {
@@ -198,6 +235,9 @@ const handleAddedNode = (node: Node) => {
}
if (node.matches?.(SELECTOR)) {
if (profilingEnabled) {
window.console.log("[react_autoinit] New component detected");
}
mountOne(node);
}
node.querySelectorAll?.(SELECTOR).forEach(mountOne);
@@ -206,7 +246,7 @@ const handleAddedNode = (node: Node) => {
/**
* Handle a removed DOM node, unmounting any React components within it.
*
* @param {Node} node The removed node to handle.
* @param node The removed node to handle.
*/
const handleRemovedNode = (node: Node) => {
if (!(node instanceof Element)) {
@@ -223,7 +263,7 @@ const handleRemovedNode = (node: Node) => {
/**
* Install a MutationObserver to handle dynamically added and removed nodes.
*
* @return {MutationObserver} The installed observer.
* @returns The installed observer.
*/
const installObserver = () => {
const obs = new MutationObserver((mutations) => {
@@ -248,8 +288,14 @@ let observer: MutationObserver | null = null;
*/
const init = async() => {
await domReady();
if (profilingEnabled) {
window.console.log("[react_autoinit] Initializing (profiling enabled)...");
}
if (!observer) {
observer = installObserver();
if (profilingEnabled) {
window.console.log("[react_autoinit] MutationObserver active");
}
}
scanAndMount(document);
};
@@ -53,7 +53,7 @@ final class esm_controller_test extends route_testcase {
// phpcs:ignore
public function __construct(private readonly string $fixture) {}
// phpcs:ignore
public function get_path_for_script(string $requestedpath): ?string {
public function get_path_for_script(int $revision, string $requestedpath): ?string {
return $this->fixture;
}
},