From a140c2ef7e8eaa192e2e7f97571efd266066ca52 Mon Sep 17 00:00:00 2001 From: meirzamoodle Date: Thu, 12 Mar 2026 10:28:07 +0700 Subject: [PATCH] MDL-87987 core: Add React profiler and dev-mode bundle switching Add core/profiler and core/mount helpers that wrap React components in 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 --- .../output/requirements/import_map.php | 53 +++++- .../route/controller/esm_controller.php | 2 +- public/lib/js/esm/build/mount.js | 11 ++ public/lib/js/esm/build/profiler.js | 8 + public/lib/js/esm/build/react_autoinit.js | 2 +- public/lib/js/esm/src/mount.ts | 103 ++++++++++++ public/lib/js/esm/src/profiler.ts | 152 ++++++++++++++++++ public/lib/js/esm/src/react_autoinit.ts | 82 +++++++--- .../route/controller/esm_controller_test.php | 2 +- 9 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 public/lib/js/esm/build/mount.js create mode 100644 public/lib/js/esm/build/profiler.js create mode 100644 public/lib/js/esm/src/mount.ts create mode 100644 public/lib/js/esm/src/profiler.ts diff --git a/public/lib/classes/output/requirements/import_map.php b/public/lib/classes/output/requirements/import_map.php index d94f56089cb..3bb6230068e 100644 --- a/public/lib/classes/output/requirements/import_map.php +++ b/public/lib/classes/output/requirements/import_map.php @@ -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 `/` * 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; diff --git a/public/lib/classes/route/controller/esm_controller.php b/public/lib/classes/route/controller/esm_controller.php index ab0722499fb..917e65f3150 100644 --- a/public/lib/classes/route/controller/esm_controller.php +++ b/public/lib/classes/route/controller/esm_controller.php @@ -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)); } diff --git a/public/lib/js/esm/build/mount.js b/public/lib/js/esm/build/mount.js new file mode 100644 index 00000000000..306bedf01a3 --- /dev/null +++ b/public/lib/js/esm/build/mount.js @@ -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 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ diff --git a/public/lib/js/esm/build/profiler.js b/public/lib/js/esm/build/profiler.js new file mode 100644 index 00000000000..3e4e628cbe8 --- /dev/null +++ b/public/lib/js/esm/build/profiler.js @@ -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 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ diff --git a/public/lib/js/esm/build/react_autoinit.js b/public/lib/js/esm/build/react_autoinit.js index 1d54a682c7b..4a1bfd5d523 100644 --- a/public/lib/js/esm/build/react_autoinit.js +++ b/public/lib/js/esm/build/react_autoinit.js @@ -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//:",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//:",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. * diff --git a/public/lib/js/esm/src/mount.ts b/public/lib/js/esm/src/mount.ts new file mode 100644 index 00000000000..ec4df45e63b --- /dev/null +++ b/public/lib/js/esm/src/mount.ts @@ -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 . + +/** + * 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 + * @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(); + +/** + * 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 `` 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

( + container: Element, + Component: ComponentType

, + 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); + } +} diff --git a/public/lib/js/esm/src/profiler.ts b/public/lib/js/esm/src/profiler.ts new file mode 100644 index 00000000000..7efd734f142 --- /dev/null +++ b/public/lib/js/esm/src/profiler.ts @@ -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 . + +/** + * Shared React Profiler helpers. + * + * @module core/profiler + * @copyright Meirza + * @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 `` 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 + * `` 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 `` 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()` to make it identifiable in React DevTools. + * + * @example + * ```tsx + * import { withProfiler } from '@moodle/core/profiler'; + * + * function MyComponent(props) { + * return

...
; + * } + * + * 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

( + Component: ComponentType

, + id?: string +): ComponentType

{ + 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; +} diff --git a/public/lib/js/esm/src/react_autoinit.ts b/public/lib/js/esm/src/react_autoinit.ts index 8cc251f5d81..746549e5dd5 100644 --- a/public/lib/js/esm/src/react_autoinit.ts +++ b/public/lib/js/esm/src/react_autoinit.ts @@ -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 void> = new WeakMap(); +const profilingEnabled = isProfilerEnabled(); /** * DOM ready promise. * - * @return {Promise} 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} 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 => { const raw = el.getAttribute("data-react-props"); @@ -82,8 +83,8 @@ const parseProps = (el: Element): Record => { * 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//` format. - * @return {Promise} The imported module, or null if resolution failed. + * @param componentName The component specifier in `@moodle/lms//` format. + * @returns The imported module, or null if resolution failed. */ const resolveComponent = async(componentName: string): Promise => { if (!componentName) { @@ -99,6 +100,11 @@ const resolveComponent = async(componentName: string): Promise => { } 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 => { } }; +/** + * Mount a single React component with profiler support. + */ +const mountReactComponent = ( + el: Element, + Component: any, + props: Record +) => { + 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); }; diff --git a/public/lib/tests/route/controller/esm_controller_test.php b/public/lib/tests/route/controller/esm_controller_test.php index dc6c3cd9686..1bbac12f74e 100644 --- a/public/lib/tests/route/controller/esm_controller_test.php +++ b/public/lib/tests/route/controller/esm_controller_test.php @@ -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; } },