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:
committed by
Adrian Greeve
co-authored by
Andrew Nicols
parent
1347d848a0
commit
a140c2ef7e
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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,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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user