|
| 1 | +--- |
| 2 | +title: Mustache Helper and Autoinit |
| 3 | +tags: |
| 4 | + - react |
| 5 | + - javascript |
| 6 | +description: How Moodle's Mustache React helper and react_autoinit work together to render, mount, and manage React components. |
| 7 | +--- |
| 8 | + |
| 9 | +import { Since } from '@site/src/components'; |
| 10 | + |
| 11 | +<Since version="5.2" issueNumber="MDL-87765" /> |
| 12 | + |
| 13 | +This page explains the combined developer contract between `mustache_react_helper` and `react_autoinit`, including how template JSON is converted into markup, how modules are resolved, and how components mount and unmount. |
| 14 | + |
| 15 | +## Purpose |
| 16 | + |
| 17 | +Use this integration when UI is produced by Mustache or fragment HTML and React components should mount automatically without manual bootstrap code. |
| 18 | + |
| 19 | +Source files: |
| 20 | + |
| 21 | +- `public/lib/classes/output/mustache_react_helper.php` |
| 22 | +- `public/lib/js/esm/src/react_autoinit.ts` |
| 23 | + |
| 24 | +## End-to-end flow |
| 25 | + |
| 26 | +1. You write a `{{#react}} ... {{/react}}` block in a Mustache template. |
| 27 | +2. `mustache_react_helper` converts the JSON config into a `<div>` with `data-react-component` and `data-react-props` attributes. |
| 28 | +3. `react_autoinit` finds the element, imports the module via the browser import map, and mounts it. |
| 29 | +4. If the region is replaced later (AJAX/fragments), components are mounted or unmounted automatically. |
| 30 | + |
| 31 | +## Mustache helper (`{{#react}}`) |
| 32 | + |
| 33 | +The `{{#react}}` block accepts a JSON object followed by optional fallback HTML content: |
| 34 | + |
| 35 | +```mustache title="mod/book/templates/view.mustache" |
| 36 | +{{#react}} |
| 37 | +{ |
| 38 | + "component": "@mod_book/viewer", |
| 39 | + "props": { |
| 40 | + "title": "{{title}}", |
| 41 | + "chapter": "{{chapter}}" |
| 42 | + }, |
| 43 | + "id": "book-viewer", |
| 44 | + "class": "book-viewer-wrapper" |
| 45 | +} |
| 46 | +<p>Loading…</p> |
| 47 | +{{/react}} |
| 48 | +``` |
| 49 | + |
| 50 | +### JSON keys |
| 51 | + |
| 52 | +| Key | Required | Maps to | |
| 53 | +|-----|----------|---------| |
| 54 | +| `component` | Yes | `data-react-component` attribute | |
| 55 | +| `props` | No | `data-react-props` attribute (JSON-encoded) | |
| 56 | +| Any other key | No | Regular HTML attribute (`id`, `class`, `aria-*`, etc.) | |
| 57 | + |
| 58 | +### Mustache tags inside the block |
| 59 | + |
| 60 | +The entire block is passed through the Mustache renderer before the JSON is parsed. This means any Mustache tag — including `{{#str}}`, `{{#quote}}`, template variables, and other helpers — can appear inside the JSON values: |
| 61 | + |
| 62 | +```mustache |
| 63 | +{{#react}} |
| 64 | +{ |
| 65 | + "component": "@mod_book/viewer", |
| 66 | + "props": { |
| 67 | + "title": "{{title}}", |
| 68 | + "confirmLabel": "{{#str}}confirm, core{{/str}}", |
| 69 | + "cancelLabel": "{{#str}}cancel, core{{/str}}" |
| 70 | + } |
| 71 | +} |
| 72 | +{{/react}} |
| 73 | +``` |
| 74 | + |
| 75 | +The rendered output is a plain string before `mustache_react_helper` attempts JSON parsing, so any valid Mustache syntax is supported. |
| 76 | + |
| 77 | +### Parsing behaviour |
| 78 | + |
| 79 | +- Mustache variables inside the block are rendered before the JSON is parsed. |
| 80 | +- Trailing commas in the JSON object are stripped automatically. |
| 81 | +- If the JSON is invalid but fallback HTML content is present, a plain `<div>` with the fallback is rendered. |
| 82 | +- If the JSON is invalid and there is no fallback content, an empty string is returned. |
| 83 | +- Invalid JSON is reported via `debugging()` at `DEBUG_DEVELOPER` level. |
| 84 | + |
| 85 | +Boolean attribute values: if a key's value is `true`, the attribute name is emitted without a value. If `false`, the attribute is omitted. |
| 86 | + |
| 87 | +## The DOM contract |
| 88 | + |
| 89 | +### `data-react-component` |
| 90 | + |
| 91 | +The component specifier must follow the format: |
| 92 | + |
| 93 | +```text |
| 94 | +@namespace/module_path |
| 95 | +``` |
| 96 | + |
| 97 | +Examples: |
| 98 | + |
| 99 | +```html |
| 100 | +<div data-react-component="@mod_book/viewer"></div> |
| 101 | +<div data-react-component="@core_calendar/event_chip"></div> |
| 102 | +``` |
| 103 | + |
| 104 | +### `data-react-props` |
| 105 | + |
| 106 | +An optional JSON object passed as the props to the React component: |
| 107 | + |
| 108 | +```html |
| 109 | +<div |
| 110 | + data-react-component="@mod_book/viewer" |
| 111 | + data-react-props='{"title":"My Book","chapter":"Chapter 1"}' |
| 112 | +></div> |
| 113 | +``` |
| 114 | + |
| 115 | +If the value is not valid JSON, `react_autoinit` logs an error and falls back to `{}`. |
| 116 | + |
| 117 | +## How module resolution works |
| 118 | + |
| 119 | +Given `@namespace/path`, `react_autoinit` builds an ESM specifier of the form: |
| 120 | + |
| 121 | +```text |
| 122 | +@moodle/lms/<component>/<path> |
| 123 | +``` |
| 124 | + |
| 125 | +Namespace conversion rules: |
| 126 | + |
| 127 | +| Namespace | Rule | Result | |
| 128 | +|-----------|------|--------| |
| 129 | +| `core` | Used as-is | `@moodle/lms/core/…` | |
| 130 | +| `core_*` | Used as-is | `@moodle/lms/core_calendar/…` | |
| 131 | +| Contains `_` | Treated as a frankenstyle plugin name | `@moodle/lms/mod_book/…` | |
| 132 | +| Anything else | Prefixed with `core_` | `@moodle/lms/core_calendar/…` | |
| 133 | + |
| 134 | +Examples: |
| 135 | + |
| 136 | +| Specifier | Resolved to | |
| 137 | +|-----------|-------------| |
| 138 | +| `@mod_book/viewer` | `@moodle/lms/mod_book/viewer` | |
| 139 | +| `@core_calendar/event_chip` | `@moodle/lms/core_calendar/event_chip` | |
| 140 | +| `@calendar/event_chip` | `@moodle/lms/core_calendar/event_chip` | |
| 141 | + |
| 142 | +The browser resolves the resulting specifier through the import map. If the import fails, mounting is skipped and an error is logged to the console. |
| 143 | + |
| 144 | +## Export contract |
| 145 | + |
| 146 | +`react_autoinit` expects a **default-exported React function component**: |
| 147 | + |
| 148 | +```tsx title="mod/book/js/esm/src/viewer.tsx" |
| 149 | +type Props = { |
| 150 | + title?: string; |
| 151 | + chapter?: string; |
| 152 | +}; |
| 153 | + |
| 154 | +export default function Viewer({title = 'Book', chapter = 'Chapter 1'}: Props) { |
| 155 | + return ( |
| 156 | + <div> |
| 157 | + <h1>{title}</h1> |
| 158 | + <p>{chapter}</p> |
| 159 | + </div> |
| 160 | + ); |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +The component is mounted with `react-dom/client` `createRoot`. If `module.default` is not found, `react_autoinit` logs a warning and skips mounting. |
| 165 | + |
| 166 | +## Lifecycle internals |
| 167 | + |
| 168 | +### Initial run |
| 169 | + |
| 170 | +`react_autoinit` calls `init()` automatically when the bundle loads. |
| 171 | + |
| 172 | +Sequence: |
| 173 | + |
| 174 | +1. Wait for `DOMContentLoaded` (or resolve immediately if the DOM is already ready). |
| 175 | +2. Scan all `[data-react-component]` elements in the target root. |
| 176 | +3. Mount each one. |
| 177 | +4. Install a single global `MutationObserver`. |
| 178 | + |
| 179 | +### Mount guard |
| 180 | + |
| 181 | +Each successfully mounted element receives `dataset.reactMounted = "1"`. This prevents duplicate mounting when the same region is rescanned. |
| 182 | + |
| 183 | +### Unmount tracking |
| 184 | + |
| 185 | +The cleanup function returned by `createRoot().unmount` is stored in a `WeakMap<Element, () => void>`. When the element is removed from the DOM, the cleanup function is called automatically. |
| 186 | + |
| 187 | +## Dynamic content (AJAX and fragments) |
| 188 | + |
| 189 | +A `MutationObserver` watches `document.documentElement` with `childList: true` and `subtree: true`. |
| 190 | + |
| 191 | +When content is added: |
| 192 | + |
| 193 | +- If the added node matches `[data-react-component]`, it is mounted. |
| 194 | +- If the added node contains matching descendants, each descendant is mounted. |
| 195 | + |
| 196 | +When content is removed: |
| 197 | + |
| 198 | +- If the removed node matches, it is unmounted. |
| 199 | +- If the removed node contains matching descendants, each descendant is unmounted. |
| 200 | + |
| 201 | +This means React components inside AJAX-loaded fragments or dynamic regions are handled automatically without any additional initializer call. |
| 202 | + |
| 203 | +## Building components |
| 204 | + |
| 205 | +Run the Grunt `react` task from the Moodle root: |
| 206 | + |
| 207 | +```bash |
| 208 | +# Production build — minified, no source maps |
| 209 | +grunt react |
| 210 | + |
| 211 | +# Development build — readable output, inline source maps |
| 212 | +grunt react:dev |
| 213 | + |
| 214 | +# Watch mode — rebuilds changed files automatically |
| 215 | +grunt react:watch |
| 216 | +``` |
| 217 | + |
| 218 | +The build tool discovers all `js/esm/src/**/*.{ts,tsx}` files across core and plugins automatically. No registration is required. |
| 219 | + |
| 220 | +## Debugging checklist |
| 221 | + |
| 222 | +If a component does not render: |
| 223 | + |
| 224 | +1. Check that `data-react-component` uses the `@namespace/path` format. |
| 225 | +2. Confirm the built file exists under `js/esm/build/`. |
| 226 | +3. Confirm the module has a default-exported function component. |
| 227 | +4. Check the browser console for messages prefixed with `[react_autoinit]`. |
| 228 | + |
| 229 | +If a component mounts multiple times: |
| 230 | + |
| 231 | +1. Ensure the container element is not recreated on every re-render by the surrounding template. |
| 232 | +2. Do not call `createRoot` manually on an element already managed by `react_autoinit`. |
| 233 | + |
| 234 | +## See also |
| 235 | + |
| 236 | +- [Mustache templates](../../templates) |
| 237 | +- [JavaScript modules](../modules) |
0 commit comments