Skip to content
Open
Show file tree
Hide file tree
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
11 changes: 7 additions & 4 deletions doc/v3/owl/reference/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ All lifecycle hooks are documented in detail in their specific
| **[onWillDestroy](component.md#willdestroy)** | just before component is destroyed |
| **[onError](component.md#onerror)** | catch and handle errors (see [error handling page](error_handling.md)) |

`onWillStart` and `onWillDestroy` also work inside a plugin's `setup()`.
`onWillStart` defers the owning `App.mount()` (or the `providePlugins` owner
component's first render) until all plugin async initialization resolves. See
[Plugins — Async Initialization](plugins.md#async-initialization).
`onWillStart`, `onMounted`, `onWillUnmount`, and `onWillDestroy` also work
inside a plugin's `setup()`. `onWillStart` defers the owning `App.mount()`
(or the `providePlugins` owner component's first render) until all plugin
async initialization resolves. `onMounted` / `onWillUnmount` fire relative to
the plugin's host (the App for app-level plugins, the owning component for
`providePlugins`). See [Plugins — Async Initialization](plugins.md#async-initialization)
and [Plugins — DOM Lifecycle](plugins.md#dom-lifecycle-onmounted--onwillunmount).

## Other Hooks

Expand Down
60 changes: 59 additions & 1 deletion doc/v3/owl/reference/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,72 @@ destroyed before initialization completes. Pass it to `fetch` or check
`abortSignal.throwIfAborted()` between awaits to short-circuit abandoned work.
See [Scope](./scope.md) for the full cancellation model.

## DOM Lifecycle: `onMounted` / `onWillUnmount`

Plugins can observe the DOM lifecycle of their host using `onMounted()` and
`onWillUnmount()` — the same hooks components use. The definition of "host"
depends on how the plugin was provided:

- **App-level plugin** (passed to `mount(...)` or `new App({plugins: [...]})`):
the host is the App itself. `onMounted` fires once, when the **first root
mounts** successfully. `onWillUnmount` fires at the start of `app.destroy()`,
before any root is torn down.
- **Component-provided plugin** (passed to `providePlugins(...)`): the host is
the component that called `providePlugins`. `onMounted` fires when that
component mounts. `onWillUnmount` fires when it is about to be destroyed
(only if it had previously mounted).

Typical use: attach browser subscriptions that only make sense while the host
is visible, or take DOM measurements.

```js
class ResizePlugin extends Plugin {
width = signal(0);

setup() {
const handler = () => this.width.set(window.innerWidth);
onMounted(() => {
window.addEventListener("resize", handler);
handler();
});
onWillUnmount(() => window.removeEventListener("resize", handler));
}
}
```

These hooks fire only once per host lifetime. They do **not** fire on
re-renders, nor when a `Portal` reparents a node, nor on a `Suspense` swap.

### Ordering

A plugin is conceptually a **child of the host that provides it** (its
`setup()` runs *during* the host's construction). Its callbacks fire in the
"child-of-host" slot:

- **Mount** (bottom-up): host's child components → plugin → host's own
`onMounted`.
- **Unmount** (top-down): host's own `onWillUnmount` → plugin → host's child
components.

Within a single host's mount/unmount batch, plugin callbacks fire relative to
host-own callbacks by call order in `setup()`. The natural idiom is to call
`providePlugins(...)` at the top of `setup()`, which yields the ordering
above.

`onMounted` / `onWillUnmount` are not fired for plugins added dynamically
(via a `Resource` of plugin constructors) after the host has already mounted
— the mount moment has passed.

## Lifecycle and Cleanup

Plugins follow a simple lifecycle:

1. The plugin is instantiated
2. `setup()` is called (may register `onWillStart` for async init)
3. The plugin is active and can be used
4. On destroy, cleanup runs in reverse order (LIFO)
4. When the host is in the DOM, `onMounted` callbacks fire; when the host is
about to leave the DOM, `onWillUnmount` callbacks fire
5. On destroy, cleanup runs in reverse order (LIFO)

All reactive values (signals, computed, effects) created during `setup()` are
automatically cleaned up when the plugin is destroyed. For manual cleanup,
Expand Down
2 changes: 2 additions & 0 deletions packages/owl-core/src/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export abstract class Scope {
status: StatusValue = STATUS.NEW;
computations: ComputationAtom[] = [];
willStart: Array<() => any> = [];
mounted: Function[] = [];
willUnmount: Function[] = [];
private _controller: AbortController | null = null;
private _destroyCbs: Array<() => void> | null = null;

Expand Down
22 changes: 22 additions & 0 deletions packages/owl-runtime/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class App extends TemplateSet {
scheduler = new Scheduler();
roots: Set<Root<any>> = new Set();
pluginManager: PluginManager;
private _pmMountedForwarded = false;

constructor(config: AppConfig = {}) {
super(config);
Expand Down Expand Up @@ -150,6 +151,21 @@ export class App extends TemplateSet {
});
preparedPromise = ready;

// On the first root mount, fire app-level plugin onMounted callbacks and
// arm the willUnmount hooks for App.destroy. Unshifted so it runs before
// the root component's own onMounted — app-level plugins wrap the App,
// mirroring how plugins wrap their host in providePlugins.
if (!this._pmMountedForwarded) {
const firstMount = () => {
if (this._pmMountedForwarded) return;
this._pmMountedForwarded = true;
const cbs = this.pluginManager.mounted;
this.pluginManager.mounted = [];
for (const cb of cbs) cb();
};
node.mounted.unshift(firstMount);
}

// Install the mount-resolve callback up front so the sync render path's
// `if (node.mounted.length)` check sees it and registers the fiber in
// root.mounted. Without this ordering the callback would never fire for
Expand Down Expand Up @@ -207,6 +223,12 @@ export class App extends TemplateSet {
}

destroy() {
if (this._pmMountedForwarded) {
for (const cb of this.pluginManager.willUnmount) {
cb();
}
this.pluginManager.willUnmount = [];
}
for (let root of this.roots) {
root.destroy();
}
Expand Down
2 changes: 0 additions & 2 deletions packages/owl-runtime/src/component_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export class ComponentNode extends Scope implements VNode<ComponentNode> {
children: { [key: string]: ComponentNode } = Object.create(null);

willUpdateProps: LifecycleHook[] = [];
willUnmount: LifecycleHook[] = [];
mounted: LifecycleHook[] = [];
willPatch: LifecycleHook[] = [];
patched: LifecycleHook[] = [];
signalComputation: ComputationAtom;
Expand Down
8 changes: 4 additions & 4 deletions packages/owl-runtime/src/lifecycle_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function onWillUpdateProps(
scope.willUpdateProps.push(scope.decorate(swapped, "onWillUpdateProps"));
}

export function onMounted(fn: (scope: ComponentNode) => void | any) {
const scope = getComponentScope();
export function onMounted(fn: (scope: Scope) => void | any) {
const scope = useScope();
scope.mounted.push(scope.decorate(fn, "onMounted"));
}

Expand All @@ -38,8 +38,8 @@ export function onPatched(fn: (scope: ComponentNode) => void | any) {
scope.patched.push(scope.decorate(fn, "onPatched"));
}

export function onWillUnmount(fn: (scope: ComponentNode) => void | any) {
const scope = getComponentScope();
export function onWillUnmount(fn: (scope: Scope) => void | any) {
const scope = useScope();
scope.willUnmount.unshift(scope.decorate(fn, "onWillUnmount"));
}

Expand Down
17 changes: 16 additions & 1 deletion packages/owl-runtime/src/plugin_hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
startPlugins,
} from "@odoo/owl-core";
import { ComponentNode, getComponentScope } from "./component_node";
import { onWillDestroy, onWillStart } from "./lifecycle_hooks";
import { onMounted, onWillDestroy, onWillStart, onWillUnmount } from "./lifecycle_hooks";
import { useScope } from "./scope";
import { STATUS } from "./status";
import { types } from "./types";
Expand Down Expand Up @@ -53,6 +53,21 @@ export function providePlugins(

startPlugins(manager, pluginConstructors);

// Forward plugin mounted/willUnmount onto the host via single wrapper hooks
// — same idiom used just below for onWillStart / above for onWillDestroy.
// The wrappers iterate `manager.{mounted,willUnmount}` at fire time, which
// preserves the LIFO unshift semantics of onWillUnmount for plugin cbs.
if (manager.mounted.length) {
onMounted(() => {
for (const cb of manager.mounted) cb();
});
}
if (manager.willUnmount.length) {
onWillUnmount(() => {
for (const cb of manager.willUnmount) cb();
});
}

if (manager.status < STATUS.MOUNTED) {
// Provided plugins registered onWillStart — defer the owning component's
// first render until they resolve.
Expand Down
Loading
Loading