Skip to content

Instantly share code, notes, and snippets.

@ged-odoo
Last active August 28, 2025 13:15
Show Gist options
  • Save ged-odoo/62810b496cc95e798fa0f0c613013fb6 to your computer and use it in GitHub Desktop.
Save ged-odoo/62810b496cc95e798fa0f0c613013fb6 to your computer and use it in GitHub Desktop.
Interactions

Notes on publicWidgets and Interactions

We refactored the publicWidget system in the public (website/...) JavaScript codebase. The initial plan was to convert them to OWL components, but after some experiments, the idea was discarded (it did not feel right, and it would prevent serving pages without OWL in the future).

See:

Silent in the nest,
owl and colibri await,
morning’s gentle glow.

Motivation

The website and JavaScript framework teams have been collaborating on a comprehensive refactoring of the publicWidget class. This is a project that aims to solve multiple issues:

  • modernize public widgets (standard JS class, more idiomatic code, no longer uses jQuery,...)
  • make it more declarative (for example, can declare t-att- attributes)
  • solve common issues (event handlers are now attached after willstart, not before)
  • a lifecycle closer to OWL, and that allows better control (start function is now synchronous, interactions can easily be stopped, started, and cleaned up)
  • bring the frontend code closer to the web client code (use same ideas/code, such as registry, helpers, environment, services)

Also, note that there has been a refactoring of the website builder (to update the code to OWL and the new html_editor), so it was the right time to do this.

Very Short Introduction

Here is what an Interaction look like:

import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";

export class HelloWorld extends Interaction {
    static selector = ".o-hello-world";
    dynamicContent = {
        "button.show-count": {
            "t-on-click": this.showNotification,
        },
        ".some-selector": {
            "t-att-class": () => ({ "d-none": this.count <= 5 }),
        },
    };

    setup() {
        this.count = 0;
    }

    showNotification() {
        this.count++;
        this.services.notification.add(`Current count: ${this.count}`);
    }
}

registry
    .category("public.interactions")
    .add("my_addon.HelloWorld", HelloWorld);

Notes:

  • The static selector value represents the target for an interaction. When a page is loaded, an interaction will be instantiated for each element matching the selector.
  • Optional static properties selectorHas and selectorNotHas allow to avoid the :has() pseudo-selector while still benefitting from its power. The goal is to support older browsers.
  • There is a dynamicContent object, which is useful to define event handlers, dynamic attributes, such as class or style, and other things.
  • The dynamic content is updated everytime an event is triggered
  • Interactions have a lifecycle similar to OWL: setup, willStart, start, destroy.
  • like for OWL components, we should not use the constructor.
  • Interactions have a direct access to the environment and to all active services, with this.env and this.services
  • The file web/static/src/public/interaction.js contains the code. It is well documented.
  • There are some useful helpers in the class as well (debounced, throttled, locked, ...)
  • Note that these helpers generally update properly the dynamic content, and also, clean up properly the DOM after the interaction has been destroyed.
  • Event handlers are attached directly on target elements, there is no event delegation.
  • Event handlers are now attached after willStart, and before start.
  • By default, interactions are not started in "edit" mode, unless they are registered in the public.interactions.edit registry
  • By default, interactions are not started in the "add snippet" dialog, unless they are registered in the public.interactions.preview registry

Converting a publicWidget to an Interaction

Rough steps:

  1. import Interaction, and change the public widget to extend Interaction instead of publicWidget (it is now a standard JavaScript class!)
  2. adapt the code (see below)
  3. at the end of the file, register it in the public interaction registry
// before
publicWidget.registry.AccountPortalSidebar = PortalSidebar.extend({
...
});
// after
export class AccountSidebar extends Sidebar {
...
}
registry
    .category("public.interactions")
    .add("account.account_sidebar", AccountSidebar);
publicWidget Interaction
this.$el this.el (note, this is an HTML element, not jQuery)
this.$('.selector') this.el.querySelector('.selector') or this.el.querySelectorAll('.selector')
.init(parent, options) .setup() (sometimes willStart if async, or even start if it interacts with the DOM)
.willStart() .willStart()
.start() .start() (is synchronous now)
.destroy() .destroy()
events dynamicContent
custom_events removed. Usually replaced by using services or triggering events on a bus
this.call('ui', 'unblock') this.services.ui.unblock()
this.trigger(...) removed. If necessary, can be replaced by el.dispatchEvent(new CustomEvent("myevent, {detail: someInfo}))
jsLibs removed. Can be replaced by await loadJS(myLib) in willStart
$(el).data("someValue") el.dataset.someValue or maybe parseInt(el.dataset.someValue)

More details

You will find here a lot more information.

Dynamic content

The dynamic content of an interaction is an object describing the set of "dynamic elements" managed by the framework: event handlers, dynamic attributes, dynamic content, sub components.

Its syntax looks like the following:

dynamicContent = {
    ".some-selector": { "t-on-click": (ev) => this.onClick(ev) },
    ".some-other-selector": {
        "t-att-class": () => ({ "some-class": true }),
        "t-att-style": () => ({ property: value }),
        "t-att-other-attribute": () => value,
        "t-out": () => value,
    },
    _root: { "t-component": () => [Component, { someProp: "value" }] },
    ".hello": { "t-on-click.stop": this.doSomething },
};
  • A selector is either a standard CSS selector, or a special keyword (see dynamicSelectors: _body, _root, _document, _window)
  • Accepted directives include: t-on-, t-att-, t-out and t-component
  • A falsy value on a class property will remove it.
  • An undefined value on a style property will remove it.
  • On others attributes:
    • false, undefined or null remove it
    • other falsy values ("", 0) are applied as such (required="")
    • boolean true is applied as the attribute's name (e.g. { "t-att-required": () => true } applies required="required")
  • Note that this is not OWL! It is similar, to make it easy to learn, but it is different, the syntax and semantics are somewhat different.
  • like OWL, event handlers can use some special suffix: .stop is useful to also stop the propagation of the event for example (see below for more info on suffixes)
  • A static INITIAL_VALUE constant is available on the Interaction class itself to get back to the initial value at some point during the interaction life.
dynamicContent = {
    ".modal": {
        "t-att-class": () => ({ show: this.someCondition ? true : Interaction.INITIAL_VALUE });
    },
};

Event handling

The Interaction system supports a few suffixes to make it easy to handle common use cases when adding an event listener. All these suffixes can be combined in any order. For example, t-on-click.prevent.capture.

  • .prevent: call .preventDefault on the event
  • .stop: call .stopPropagation on the event
  • .capture: add the listener in capture mode (not bubbling)
  • .once: activate the once option (the handler will be removed after it fires once)
  • .noUpdate: prevent the interaction from updating the dynamic content. It is quite rare to need to do so.
  • .withTarget: add the current event target as an additional argument to the handler. It is useful in some functions that may lose the information because they execute later (debounced, throttled, locked)

Dynamic selectors

Interactions can target "dynamic selectors", which are special keywords that will call a function to target some element that may even be outside the root of the interaction. By convention, they are prefixed with _. One of the most common use for dynamic selectors is to target the root of the interaction:

dynamicContent = {
    _root: { "t-on-click": (ev) => this.onClick(ev) },
};

By default, they are defined like this:

dynamicSelectors = {
    _root: () => this.el,
    _body: () => this.el.ownerDocument.body,
    _window: () => window,
    _document: () => this.el.ownerDocument,
};

But the definition can be extended. For exemple, to target a modal element outside the interaction:

dynamicSelectors = Object.assign(this.dynamicSelectors, {
    _modal: () => this.el.closest(".modal"),
    _bg: () => this.el.querySelector(":scope > .s_parallax_bg"),
});
dynamicContent = {
    _modal: { "t-on-shown.bs.modal": this.updateBackgroundHeight },
};

Dynamic selectors can match any number of elements.

Helpers

Interaction defines a few helpers to make it easy to handle common usecases properly, which is sometimes trickier than one might expect. This list is not set in stone. If you believe that you have a useful generic usecase, please reach out to us to see if adding it to the Interaction class is a good idea.

updateContent: force the framework to apply immediately the dynamic content. It is usually done after each event handler is executed, so this function is mostly useful when dealing with asynchronous code

waitFor(promise): wrap a promise into a promise that will only be resolved if the interaction has not been destroyed. It is the primary way to write safe asynchronous code:

const data = await this.waitFor(rpc(...));

Note that waitFor should not be used inside willStart: you can just call the promise directly.

protectSyncAfterAsync(fn): after a waitFor, wraps synchronous code that might use the result from the promise. The code is protected even when the promise was cancelled because the interaction was destroyed.

const data = await this.waitFor(rpc(...));
this.protectSyncAfterAsync(() => {
    // this line is only executed if the interaction has not been destroyed
    this.updateDOM(data);
});

waitForTimeout(fn, delay): executes a function after a specific timeout, if the interaction has not been destroyed. The dynamic content is then applied.

waitForAnimationFrame(fn): executes a function after an animation frame, if the interaction has not been destroyed. The dynamic content is then applied.

debounced(fn, delay): debounces a function and makes sure it is cancelled upon destroy.

throttled(fn): throttles a function for animation and makes sure it is cancelled upon destroy.

locked(fn, useLoadingAnimation=false): makes sure the callback is not called again before the current one is completed. A loading animation can be added on the button if the execution takes more than 400ms. locked is useful on events that an unsuspecting user could trigger several times in a row.

addListener(target, event, fn, options): adds a listener to one or a list of targets, and automatically removes it when the interaction is destroyed. The method returns a function to remove the listeners it attached. It works the same way as t-on- directives in the dynamicContent, but it is useful in cases where a listener should be added or removed during the interaction's life.

insert(el, locationEl=this.el, position="beforeend", removeOnClean=true): inserts an element at a specific position (beforebegin, afterbegin, beforeend, afterend), and activates interactions if it contains any. If removeOnClean is true (default), the inserted element will be removed when the interaction is destroyed.

renderAt(template, renderContext, locationEl, position="beforeend", callback, removeOnClean=true): renders an OWL template and inserts the rendered elements at a specific position (beforebegin, afterbegin, beforeend, afterend). Interactions are activated. If a callback is passed, it will be called after rendering the template, but before inserting the elements to the DOM. If removeOnClean is true (default), the inserted element will be removed when the interaction is destroyed.

removeChildren(el, insertBackOnClean=true): removes the children of an element. If insertBackOnClean is true (default), they will be reintroduced when the interaction is destroyed.

registerCleanup(fn): registers a function to be executed when the interaction is destroyed. This helps keeping the code delimited: we add the cleanup at the place the side effect is created.

mountComponent(el, C, props=null, position="beforeend"): mounts an OWL component (C) within the targeted element. props should either be an object or remain null.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment