Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions docs/guides/javascript/react/importmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
title: Aliasing and Import Maps
tags:
- Javascript
- ESM
- React
- Import Maps
description: How Moodle uses native browser import maps to resolve bare module specifiers to real URLs at runtime, including built-in specifiers, custom entries, and the ESM serving endpoint.
---

Moodle uses native browser import maps as the mechanism for resolving bare module specifiers (for example `react` or `@moodle/lms/`) to real URLs at runtime. This replaces the need for bundler-specific alias configuration and allows Moodle components to write standard ESM `import` statements that work directly in the browser.

## What is an Import Map?

An import map is a JSON object, embedded in the page as a `<script type="importmap">` tag, that tells the browser how to resolve bare specifiers used in `import` statements.

```html
<script type="importmap">
{
"imports": {
"@moodle/lms/": "http://example.com/r.php/-1/",
"react": "http://example.com/r.php/-1/external/react",
}
}
</script>
```

With this map in place, any ES module on the page can write:

```js
import React from 'react';
import { someUtil } from '@moodle/lms/core/utils';
```

…and the browser resolves the specifier to the correct URL without any bundler step at runtime.

## How Moodle generates the Import Map

The import map is built and injected into the page automatically by `page_requirements_manager::get_import_map()`, which is called during the page `<head>` render phase.

### The `import_map` class

**`core\output\requirements\import_map`** is the central registry that holds all specifier-to-URL mappings. It implements `JsonSerializable` so it can be written directly into the page as JSON.

Key responsibilities:

- Holds a list of specifier → loader entries.
- Accepts a **default loader URL** (the ESM serving endpoint) which is used to derive concrete URLs for all entries whose path is relative.
- Provides `add_import()` to register additional specifiers, or to override the built-in ones, from a `pre_render` hook.

#### Built-in specifiers

The following specifiers are registered by default in `add_standard_imports()`:

| Specifier | Resolves to |
|---|---|
| `@moodle/lms/` | ESM endpoint root (Moodle component modules) |
| `react` | `external/react` |
| `react-dom` | `external/react-dom` |
| `react/jsx-runtime` | `external/react/jsx-runtime` |
| `react/jsx-dev-runtime` | `external/react/jsx-dev-runtime` |
| `@moodlehq/design-system` | `external/design-system` |

#### Adding a custom specifier

You can extend the import map from a `pre_render` hook before the page is rendered:

```php
use core\output\requirements\import_map;

// Fetch the shared singleton from the DI container.
$importmap = \core\di::get(import_map::class);

// Map 'my-lib' to an absolute URL.
$importmap->add_import('my-lib', loader: new \core\url('https://cdn.example.com/my-lib.js'));

// Map '@myplugin/' using a path relative to the default ESM loader root.
$importmap->add_import('@myplugin/', path: 'local_myplugin/');
```

The `add_import()` signature is:

```php
public function add_import(
string $specifier,
?\core\url $loader = null,
?string $path = null,
): void
```

- **`$specifier`** — The bare specifier string used in `import` statements (e.g. `react`, `@moodle/lms/`).
- **`$loader`** — An absolute `\core\url`. When provided, used directly.
- **`$path`** — A path suffix appended to the default loader base URL. When both `$loader` and `$path` are `null`, `$specifier` itself is appended to the base URL.

## The ESM serving endpoint

All ESM files are served by `core\route\shim\esm_controller::serve`, registered under the route:

```
/{revision:[0-9-]+}/{scriptpath:.*}
```

The controller dispatches on the value of `scriptpath`:

### External (vendored) bundles — `external/`

Paths that begin with `external/` are resolved against a fixed map of vendored files stored under `$CFG->root/lib/platform_bundles/`:

| Specifier (after `external/`) | File |
|---|---|
| `react` | `lib/platform_bundles/react/<version>/react.js` |
| `react-dom` | `lib/platform_bundles/react/<version>/react-dom-client.js` |
| `react/jsx-runtime` | `lib/platform_bundles/react/<version>/jsx-runtime.js` |
| `react/jsx-dev-runtime` | `lib/platform_bundles/react/<version>/jsx-dev-runtime.js` |
| `design-system` | `lib/platform_bundles/moodle-design-system/<version>/index.js` |

### Component React builds — everything else

Any `scriptpath` that does not start with `external/` is treated as a `<component>/<module>` alias and resolved to:

```
<component_directory>/react/build/<module>.js
```

For example, `mod_book/viewer` resolves to `<dirroot>/mod/book/react/build/viewer.js`.

## HTTP caching

The `esm_script_serving` trait, mixed into the controller, applies the following caching strategy:

| Revision | Behaviour |
|---|---|
| Valid (positive integer) | Long-lived immutable cache headers + ETag. Returns `304 Not Modified` when the client already has the file cached. |
| `-1` (development / invalid) | Short-lived cache headers. Forces the browser to re-fetch on every page load. |

The revision value comes from `page_requirements_manager::get_jsrev()` and changes whenever JavaScript files are updated, automatically busting caches.

## Writing a component React module

To expose a React module through the import map:

1. Build your TypeScript/React source (`.ts` or `.tsx`) into a compiled JavaScript file at `<component_directory>/js/react/build/<module>.js` using the Moodle build tools. See the React build tooling guide for details.
2. Import it in browser code using the `@moodle/lms/` scope:

```js
// In any ES module on the page:
import { BookViewer } from '@moodle/lms/mod_book/viewer';
```

The import map translates `@moodle/lms/mod_book/viewer` → the ESM endpoint URL for `mod_book/viewer`, which the controller then resolves to `<dirroot>/mod/book/js/react/build/viewer.js`.

:::note
The `@moodle/lms/` specifier ends with a trailing slash. This is the standard import map convention for **package scopes**: any import that begins with `@moodle/lms/` will be prefixed with the loader base URL, allowing the entire namespace to be served from one endpoint without registering each module individually.
:::

## Overriding a built-in specifier

You can replace any of the default specifiers in a `pre_render` hook. For example, to swap in a local React build during development:

```php
$importmap = \core\di::get(\core\output\requirements\import_map::class);
$importmap->add_import(
'react',
loader: new \core\url('/local/devtools/react-debug.js'),
);
```

Calling `add_import()` with the same specifier twice overwrites the previous entry, so ordering matters when multiple hooks are involved.

## See also

- [JavaScript Modules](../modules.md) — AMD and ESM module authoring in Moodle.
- [MDN: Import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap)
- [HTML spec: import maps](https://html.spec.whatwg.org/multipage/webappapis.html#import-maps)