Compare commits

..

12 Commits

Author SHA1 Message Date
Xavier Morel 9edd31f755 [ADD] runtime/utils: optional validation to EventBus
Currently the event bus allows sending and listening to arbitrary
events, I got got by that when I pushed a fix using `addEventListener`
on a bus across an events renaming, and on the other side the fix did
nothing anymore. Entirely my fault, but if the list of events sent on
a bus is known and documented (e.g. a jsdoc has `@emits` tags) it
would make sense for both the listening and the dispatching to also be
validated.

This proposal performs validation only when the eventbus is created:

- in dev mode (which also requires being in a component context)
- if an iterable of events is passed to the ctor

The dev-mode check might be overkill but it seems like a good idea at
least for an initial version, as the validation does have a cost
however low, and validation errors can occur essentially anywhere.
2025-09-12 14:09:38 +02:00
Damien Bouvy c2728c9daf [DOC] slots prop validation
Make it explicit that props validation should accept `slots` if a component uses slots (even the `default` slot).
2025-09-03 13:46:45 +02:00
Romeo Fragomeli 871dad6a13 [REL] v2.8.0
# v2.8.0

 - [FIX] parser: t-esc/t-out vs t-translation*
 - [IMP] compiler: make human-readable ARIA attributes translatable
2025-06-30 14:46:16 +02:00
Mathieu Duckerts-Antoine b620502a0f [FIX] parser: t-esc/t-out vs t-translation*
The directives t-esc/t-out both discard the ASTs that are not of DomNode
type. Since the directives t-translation and t-translation-context both
creates AST wrappers of type TTranslation and TTranslationContext
respectively, mix t-esc/t-out and t-translation/t-translation-context
does not work. For example parse

<span t-esc="'Hello'" t-translation="off"/>

gives the AST

{
    type: ASTType.TEsc,
    expr: "'Hello'",
    defaultValue: "",
}

This makes the span not been rendered in the end.

We fix that problem.
2025-06-24 16:03:56 +02:00
Louis Wicket (wil) 89cb00cc83 [IMP] compiler: make human-readable ARIA attributes translatable
ARIA attributes containing human-readable text should be translated.
This commit adds human-readable ARIA attributes to the list of the
attributes translated by OWL.
2025-04-03 21:40:27 +02:00
Romeo Fragomeli 56041bc133 [REL] v2.7.0
# v2.7.0

 - [IMP] runtime/utils: export htmlEscape and add tests
 - [FIX] utils: Correct validation of mount target in shadow DOM/iframe
 - [IMP] runtime: add markup tag function
2025-03-26 13:58:50 +01:00
Sébastien Theys e788e361c7 [IMP] runtime/utils: export htmlEscape and add tests
markup tag function requires markup awareness to determine whether a
given parameter should be escaped or not.

This implies that pre-escaped content should be properly marked'ed up to
avoid double escaping. Having to manually wrap all calls to escape
with markup is cumbersome and prone to issues (on top of having to be
validated by the security team for no reason).

This commit introduces a markup-aware escape function to resolve those
issues.
2025-03-26 13:52:35 +01:00
Achraf (abz) 9d378b0e7b [FIX] utils: Correct validation of mount target in shadow DOM/iframe
Previously, `validateTarget` only checked if the target element or its
host (if it was a ShadowRoot) was directly contained in the document body.
This failed in cases where the target element was nested inside a shadow
DOM, which itself was attached to the document.

This commit introduces a new helper `isAttachedToDocument` that
traverses through parent nodes and shadow roots to ensure that the
target is ultimately attached to the given document.
Additionally, it now throws a clear error if `document.defaultView` is
missing, indicating that the target document is detached or invalid.

This ensures proper validation of mount targets, including complex
scenarios with shadow roots and iframes.
2025-03-25 21:11:39 +01:00
Louis Wicket (wil) fd3c194525 [IMP] runtime: add markup tag function
Allows markup to be called as a tag function. The interpolated strings
are then safely escaped for injection in HTML code.

Example usage:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
const value = markup`<b>${maliciousInput}</b>`;
// no problem, maliciousInput is properly escaped
```
2025-03-25 15:39:26 +01:00
Romeo Fragomeli ac9ccb81ca [REL] v2.6.1
# v2.6.1

 - [FIX] code generator: prevent AST change
 - [IMP] runtime: simplify info message when running in dev mode
2025-03-05 09:38:08 +01:00
Michaël Mattiello 2b5cea944b [FIX] code generator: prevent AST change
This commit removes an AST change during the code generation of slots.
Before, the `compileTSlot` function deleted the `t-props` attribute
directly on `ast.attrs`. This creates wrong code when compiling a
second time as the `t-props` attribute does not exist anymore.
2025-03-05 09:27:43 +01:00
Géry Debongnie cf8039f643 [IMP] runtime: simplify info message when running in dev mode
The owl dev info message may be useful, but does not bring that much
value. Also, this is even slightly annoying while debugging odoo, since
it is common to have to go to dev mode, and the message takes some
visual space, which is a distraction.  In this commit, we simplify it to
just warn that owl is in dev mode.
2025-01-16 15:55:11 +01:00
19 changed files with 674 additions and 59 deletions
+22
View File
@@ -320,6 +320,28 @@ class ComponentB extends owl.Component {
Note: the props validation code is done by using the [validate utility function](utils.md#validate).
### `slots` prop
If a component that uses [slots](slots.md) also lists or validates its props, then
you will have to explicitely allow the `slots` prop (with an `Object` type), or
allow extra props using the `*` notation mentioned above. This is because slots
are provided to a component [as props](slots.md#slots-and-props).
For example:
```js
class MyComponent extends Component {
static props = [someProp, slots?];
}
class MyComponentWithValidation extends Component {
static props = {
someProp: {type: Number, optional: true},
slots : {type: Object, optional: true},
}
}
```
## Good Practices
A `props` object is a collection of values that come from the parent. As such,
+9
View File
@@ -193,6 +193,15 @@ The first `t-out` will act as a `t-esc` directive, which means that the content
of `value1` will be escaped. However, since `value2` has been tagged as a markup,
this will be injected as html.
`markup` can also be used as a tag function, allowing the interpolated values to
be safely escaped:
```js
const maliciousInput = "<script>alert('💥💥')</script>";
// <b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>
const value = markup`<b>${maliciousInput}</b>`;
```
### Setting Variables
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
+82 -27
View File
@@ -276,13 +276,39 @@ function inOwnerDocument(el) {
const rootNode = el.getRootNode();
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
}
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(element, documentElement) {
let current = element;
const shadowRoot = documentElement.defaultView.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
}
else if (current instanceof shadowRoot && current.host) {
current = current.host;
}
else {
return false;
}
}
return false;
}
function validateTarget(target) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument;
if (document) {
if (!document.defaultView) {
throw new OwlError("Cannot mount a component: the target document is not attached to a window (defaultView is missing)");
}
const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) {
if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node");
}
return;
@@ -319,12 +345,40 @@ async function loadFile(url) {
*/
class Markup extends String {
}
/*
* Marks a value as safe, that is, a value that can be injected as HTML directly.
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering.
*/
function markup(value) {
return new Markup(value);
function htmlEscape(str) {
if (str instanceof Markup) {
return str;
}
if (str === undefined) {
return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
function markup(valueOrStrings, ...placeholders) {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
}
function createEventHandler(rawEvent) {
@@ -3796,7 +3850,16 @@ class CodeTarget {
return key;
}
}
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const TRANSLATABLE_ATTRS = [
"alt",
"aria-label",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"label",
"placeholder",
"title",
];
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
class CodeGenerator {
constructor(ast, options) {
@@ -4801,16 +4864,15 @@ class CodeGenerator {
isMultiple = isMultiple || this.slotNames.has(ast.name);
this.slotNames.add(ast.name);
}
const dynProps = ast.attrs ? ast.attrs["t-props"] : null;
if (ast.attrs) {
delete ast.attrs["t-props"];
}
const attrs = { ...ast.attrs };
const dynProps = attrs["t-props"];
delete attrs["t-props"];
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) {
key = this.generateComponentKey(key);
}
const props = ast.attrs
? this.formatPropObject(ast.attrs, ast.attrsTranslationCtx, ctx.translationCtx)
? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) {
@@ -4915,11 +4977,11 @@ function parseNode(node, ctx) {
parseTPortal(node, ctx) ||
parseTCall(node, ctx) ||
parseTCallBlock(node) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -5705,7 +5767,7 @@ function compile(template, options = {
}
// do not modify manually. This file is generated by the release script.
const version = "2.6.0";
const version = "2.8.0";
// -----------------------------------------------------------------------------
// Scheduler
@@ -5800,13 +5862,6 @@ class Scheduler {
Scheduler.requestAnimationFrame = window.requestAnimationFrame.bind(window);
let hasBeenLogged = false;
const DEV_MSG = () => {
const hash = window.owl ? window.owl.__info__.hash : "master";
return `Owl is running in 'dev' mode.
This is not suitable for production use.
See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration for more information.`;
};
const apps = new Set();
window.__OWL_DEVTOOLS__ || (window.__OWL_DEVTOOLS__ = { apps, Fiber, RootFiber, toRaw, reactive });
class App extends TemplateSet {
@@ -5823,7 +5878,7 @@ class App extends TemplateSet {
}
this.warnIfNoStaticProps = config.warnIfNoStaticProps || false;
if (this.dev && !config.test && !hasBeenLogged) {
console.info(DEV_MSG());
console.info(`Owl is running in 'dev' mode.`);
hasBeenLogged = true;
}
const env = config.env || {};
@@ -6180,9 +6235,9 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
});
};
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, htmlEscape, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2025-01-15T10:40:24.184Z';
__info__.hash = 'a9be149';
__info__.date = '2025-06-30T12:46:06.424Z';
__info__.hash = 'b620502';
__info__.url = 'https://github.com/odoo/owl';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.6.0",
"version": "2.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.6.0",
"version": "2.8.0",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
+14 -6
View File
@@ -254,7 +254,16 @@ class CodeTarget {
}
}
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const TRANSLATABLE_ATTRS = [
"alt",
"aria-label",
"aria-placeholder",
"aria-roledescription",
"aria-valuetext",
"label",
"placeholder",
"title",
];
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
export class CodeGenerator {
@@ -1350,17 +1359,16 @@ export class CodeGenerator {
isMultiple = isMultiple || this.slotNames.has(ast.name);
this.slotNames.add(ast.name);
}
const dynProps = ast.attrs ? ast.attrs["t-props"] : null;
if (ast.attrs) {
delete ast.attrs["t-props"];
}
const attrs = { ...ast.attrs };
const dynProps = attrs["t-props"];
delete attrs["t-props"];
let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
if (isMultiple) {
key = this.generateComponentKey(key);
}
const props = ast.attrs
? this.formatPropObject(ast.attrs, ast.attrsTranslationCtx, ctx.translationCtx)
? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) {
+2 -2
View File
@@ -253,11 +253,11 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
parseTPortal(node, ctx) ||
parseTCall(node, ctx) ||
parseTCallBlock(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTEscNode(node, ctx) ||
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
+1 -10
View File
@@ -29,15 +29,6 @@ export interface AppConfig<P, E> extends TemplateSetConfig, RootConfig<P, E> {
let hasBeenLogged = false;
export const DEV_MSG = () => {
const hash = (window as any).owl ? (window as any).owl.__info__.hash : "master";
return `Owl is running in 'dev' mode.
This is not suitable for production use.
See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration for more information.`;
};
const apps = new Set<App>();
declare global {
@@ -88,7 +79,7 @@ export class App<
}
this.warnIfNoStaticProps = config.warnIfNoStaticProps || false;
if (this.dev && !config.test && !hasBeenLogged) {
console.info(DEV_MSG());
console.info(`Owl is running in 'dev' mode.`);
hasBeenLogged = true;
}
const env = config.env || {};
+1 -1
View File
@@ -41,7 +41,7 @@ export { useComponent, useState } from "./component_node";
export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { batched, EventBus, whenReady, loadFile, markup } from "./utils";
export { batched, EventBus, htmlEscape, whenReady, loadFile, markup } from "./utils";
export {
onWillStart,
onMounted,
+117 -4
View File
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error";
import { ComponentNode, getCurrent } from "./component_node";
export type Callback = () => void;
/**
@@ -35,13 +36,43 @@ export function inOwnerDocument(el?: HTMLElement) {
return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host);
}
/**
* Determine whether the given element is contained in a specific root documnet:
* either directly or with a shadow root in between or in an iframe.
*/
function isAttachedToDocument(
element: HTMLElement | ShadowRoot,
documentElement: Document
): boolean {
let current: Node = element;
const shadowRoot = documentElement.defaultView!.ShadowRoot;
while (current) {
if (current === documentElement) {
return true;
}
if (current.parentNode) {
current = current.parentNode;
} else if (current instanceof shadowRoot && current.host) {
current = current.host;
} else {
return false;
}
}
return false;
}
export function validateTarget(target: HTMLElement | ShadowRoot) {
// Get the document and HTMLElement corresponding to the target to allow mounting in iframes
const document = target && target.ownerDocument;
if (document) {
const HTMLElement = document.defaultView!.HTMLElement;
if (!document.defaultView) {
throw new OwlError(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
}
const HTMLElement = document.defaultView.HTMLElement;
if (target instanceof HTMLElement || target instanceof ShadowRoot) {
if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) {
if (!isAttachedToDocument(target, document)) {
throw new OwlError("Cannot mount a component on a detached dom node");
}
return;
@@ -51,10 +82,52 @@ export function validateTarget(target: HTMLElement | ShadowRoot) {
}
export class EventBus extends EventTarget {
constructor(events?: string[]) {
if (events) {
let node: ComponentNode | null = null;
try {
node = getCurrent();
} catch {}
if (node?.app?.dev) {
return new DebugEventBus(events);
}
}
super();
}
trigger(name: string, payload?: any) {
this.dispatchEvent(new CustomEvent(name, { detail: payload }));
}
}
class DebugEventBus extends EventBus {
private events: Set<string>;
constructor(events: string[]) {
super();
this.events = new Set(events);
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions
): void {
if (!this.events.has(type)) {
throw new OwlError(`EventBus: subscribing to unknown event '${type}'`);
}
super.addEventListener(type, listener, options);
}
trigger(name: string, payload?: any) {
if (!this.events.has(name)) {
throw new OwlError(`EventBus: triggering unknown event '${name}'`);
}
super.trigger(name, payload);
}
dispatchEvent(event: Event): boolean {
if (!this.events.has(event.type)) {
throw new OwlError(`EventBus: dispatching unknown event '${event.type}'`);
}
return super.dispatchEvent(event);
}
}
export function whenReady(fn?: any): Promise<void> {
return new Promise(function (resolve) {
@@ -81,10 +154,50 @@ export async function loadFile(url: string): Promise<string> {
*/
export class Markup extends String {}
export function htmlEscape(str: any): Markup {
if (str instanceof Markup) {
return str;
}
if (str === undefined) {
return markup("");
}
if (typeof str === "number") {
return markup(String(str));
}
[
["&", "&amp;"],
["<", "&lt;"],
[">", "&gt;"],
["'", "&#x27;"],
['"', "&quot;"],
["`", "&#x60;"],
].forEach((pairs) => {
str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]);
});
return markup(str);
}
/*
* Marks a value as safe, that is, a value that can be injected as HTML directly.
* It should be used to wrap the value passed to a t-out directive to allow a raw rendering.
*
* If called as a tag function, the interpolated strings are escaped.
*/
export function markup(value: any) {
return new Markup(value);
export function markup(strings: TemplateStringsArray, ...placeholders: unknown[]): Markup;
export function markup(value: string): Markup;
export function markup(
valueOrStrings: string | TemplateStringsArray,
...placeholders: unknown[]
): Markup {
if (!Array.isArray(valueOrStrings)) {
return new Markup(valueOrStrings);
}
const strings = valueOrStrings;
let acc = "";
let i = 0;
for (; i < placeholders.length; ++i) {
acc += strings[i] + htmlEscape(placeholders[i]);
}
acc += strings[i];
return new Markup(acc);
}
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script.
export const version = "2.6.0";
export const version = "2.8.0";
@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-slot compile t-props correctly multiple time 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
return function template(ctx, node, key = \\"\\") {
return callSlot(ctx, node, key, 'default', false, Object.assign({}, {a:1}));
}
}"
`;
+98
View File
@@ -1971,6 +1971,54 @@ describe("qweb parser", () => {
});
});
test('t-translation="off": interaction with t-esc', async () => {
expect(parse(`<span t-esc="a" t-translation="off"/>`)).toEqual({
type: ASTType.TTranslation,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
defaultValue: "",
expr: "a",
type: ASTType.TEsc,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
});
});
test('t-translation="off": interaction with t-out', async () => {
expect(parse(`<span t-out="a" t-translation="off"/>`)).toEqual({
type: ASTType.TTranslation,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
body: null,
expr: "a",
type: ASTType.TOut,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
});
});
// ---------------------------------------------------------------------------
// t-translation-context
// ---------------------------------------------------------------------------
@@ -2008,6 +2056,56 @@ describe("qweb parser", () => {
});
});
test("t-translation-context: interaction with t-esc", async () => {
expect(parse(`<span t-esc="a" t-translation-context="fr"/>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
defaultValue: "",
expr: "a",
type: ASTType.TEsc,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
translationCtx: "fr",
});
});
test("t-translation-context: interaction with t-out", async () => {
expect(parse(`<span t-out="a" t-translation-context="fr"/>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
body: null,
expr: "a",
type: ASTType.TOut,
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "span",
type: ASTType.DomNode,
},
translationCtx: "fr",
});
});
// ---------------------------------------------------------------------------
// t-translation-context-attr
// ---------------------------------------------------------------------------
+15
View File
@@ -0,0 +1,15 @@
import { parseXML } from "../../src/common/utils";
import { compile } from "../../src/compiler";
describe("t-slot", () => {
test("compile t-props correctly multiple time", () => {
const template = `<t t-slot="default" t-props="{ a: 1 }"/>`;
const parsedTemplate = parseXML(template).firstChild as Element;
const fn1 = compile(parsedTemplate);
expect(fn1.toString()).toMatchSnapshot();
const fn2 = compile(parsedTemplate);
expect(fn2.toString()).toBe(fn1.toString());
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers";
import { Component, onError, xml, mount, OwlError, useState } from "../../src";
import { App, DEV_MSG } from "../../src/runtime/app";
import { App } from "../../src/runtime/app";
import { validateProps } from "../../src/runtime/template_helpers";
import { Schema } from "../../src/runtime/validation";
@@ -13,7 +13,7 @@ let mockConsoleWarn: any;
beforeAll(() => {
console.info = (message: any) => {
if (message === DEV_MSG()) {
if (message === `Owl is running in 'dev' mode.`) {
return;
}
info(message);
+1 -2
View File
@@ -11,7 +11,6 @@ import {
useState,
} from "../../src";
import { xml } from "../../src/";
import { DEV_MSG } from "../../src/runtime/app";
import { elem, makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers";
let fixture: HTMLElement;
@@ -30,7 +29,7 @@ snapshotEverything();
beforeAll(() => {
console.info = (message: any) => {
if (message === DEV_MSG()) {
if (message === `Owl is running in 'dev' mode.`) {
return;
}
info(message);
@@ -27,6 +27,58 @@ exports[`shadow_dom can mount app 1`] = `
}"
`;
exports[`shadow_dom can mount app in closed shadow dom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a separate HTML document 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside a shadow child element 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom can mount app inside an element in a shadow root inside an iframe 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`shadow_dom useRef hook 1`] = `
"function anonymous(app, bdom, helpers
) {
+87
View File
@@ -29,6 +29,24 @@ describe("shadow_dom", () => {
expect(status(comp)).toBe("destroyed");
});
test("can mount app in closed shadow dom", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const container = document.createElement("div");
fixture.appendChild(container);
const shadow = container.attachShadow({ mode: "closed" });
const app = new App(SomeComponent);
const comp = await app.mount(shadow);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can bind event handler", async () => {
let a = 1;
class SomeComponent extends Component {
@@ -64,4 +82,73 @@ describe("shadow_dom", () => {
await mountedProm;
expect(comp!.div.el).toBe(shadow.querySelector(".my-div"));
});
test("can mount app inside a shadow child element", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const shadow = fixture.attachShadow({ mode: "open" });
const shadowDiv = document.createElement("div");
shadow.append(shadowDiv);
const app = new App(SomeComponent);
const comp = await app.mount(shadowDiv);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
test("can mount app inside a separate HTML document", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const separateDoc = document.implementation.createHTMLDocument();
const container = separateDoc.createElement("div");
separateDoc.body.appendChild(container);
const app = new App(SomeComponent);
let error: Error;
try {
await app.mount(container);
} catch (e) {
error = e as Error;
}
expect(error!).toBeDefined();
expect(error!.message).toBe(
"Cannot mount a component: the target document is not attached to a window (defaultView is missing)"
);
});
test("can mount app inside an element in a shadow root inside an iframe", async () => {
class SomeComponent extends Component {
static template = xml`<div class="my-div"/>`;
}
const iframe = document.createElement("iframe");
fixture.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
const container = iframeDoc.createElement("div");
iframeDoc.body.appendChild(container);
const shadow = container.attachShadow({ mode: "open" });
const shadowTarget = iframeDoc.createElement("div");
shadow.appendChild(shadowTarget);
const app = new App(SomeComponent);
const comp = await app.mount(shadowTarget);
const div = shadow.querySelector(".my-div");
expect(div).not.toBe(null);
expect(shadow.contains(div)).toBe(true);
expect(iframeDoc.body.contains(container)).toBe(true);
app.destroy();
expect(shadow.contains(div)).toBe(false);
expect(status(comp)).toBe("destroyed");
});
});
+155 -2
View File
@@ -1,5 +1,7 @@
import { batched, EventBus } from "../src/runtime/utils";
import { nextMicroTick } from "./helpers";
import { batched, EventBus, htmlEscape, markup } from "../src/runtime/utils";
import { makeTestFixture, nextMicroTick } from "./helpers";
import { getCurrent } from "../src/runtime/component_node";
import { Component, mount, xml } from "../src";
describe("event bus behaviour", () => {
test("can subscribe and be notified", () => {
@@ -33,6 +35,66 @@ describe("event bus behaviour", () => {
bus.addEventListener("event", (ev: any) => expect(ev.detail).toBe("hello world"));
bus.trigger("event", "hello world");
});
test("events are not validated if the bus is created outside of dev mode", async () => {
let bus_empty: EventBus | null = null;
class Root extends Component {
static template = xml`<div/>`;
setup() {
getCurrent(); // checks that we're in a component context
bus_empty = new EventBus([]);
}
}
await mount(Root, makeTestFixture());
bus_empty!.addEventListener("a", () => {});
bus_empty!.trigger("a");
bus_empty!.dispatchEvent(new CustomEvent("a"));
});
test("events are validated if the bus is created in dev mode & events are provided", async () => {
let bus: EventBus | null = null;
let bus_empty: EventBus | null = null;
let bbus_no_validation: EventBus | null = null;
class Root extends Component {
static template = xml`<div/>`;
setup() {
getCurrent(); // checks that we're in a component context
bus = new EventBus(["a", "b"]);
bus_empty = new EventBus([]);
bbus_no_validation = new EventBus();
}
}
await mount(Root, makeTestFixture(), { test: true });
bbus_no_validation!.addEventListener("c", () => {});
bbus_no_validation!.trigger("c");
bbus_no_validation!.dispatchEvent(new CustomEvent("c"));
bus!.addEventListener("a", () => {});
bus!.trigger("a");
bus!.dispatchEvent(new CustomEvent("a"));
expect(() => bus!.addEventListener("c", () => {})).toThrow(
"EventBus: subscribing to unknown event 'c'"
);
expect(() => bus!.trigger("c")).toThrow("EventBus: triggering unknown event 'c'");
expect(() => bus!.dispatchEvent(new CustomEvent("c"))).toThrow(
"EventBus: dispatching unknown event 'c'"
);
expect(() => bus_empty!.addEventListener("a", () => {})).toThrow(
"EventBus: subscribing to unknown event 'a'"
);
expect(() => bus_empty!.trigger("a")).toThrow("EventBus: triggering unknown event 'a'");
expect(() => bus_empty!.dispatchEvent(new CustomEvent("a"))).toThrow(
"EventBus: dispatching unknown event 'a'"
);
});
});
describe("batched", () => {
@@ -71,3 +133,94 @@ describe("batched", () => {
expect(n).toBe(2);
});
});
const Markup = markup("").constructor;
describe("markup", () => {
test("string is flagged as safe", () => {
const html = markup("<blink>Hello</blink>");
expect(html).toBeInstanceOf(Markup);
});
describe("htmlEscape", () => {
test("htmlEscape escapes text", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape keeps html markup", () => {
const res = htmlEscape(markup("<p>test</p>"));
expect(res.toString()).toBe("<p>test</p>");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces empty string on undefined", () => {
const res = htmlEscape(undefined);
expect(res.toString()).toBe("");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from number", () => {
const res = htmlEscape(10);
expect(res.toString()).toBe("10");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape produces string from boolean", () => {
const res = htmlEscape(false);
expect(res.toString()).toBe("false");
expect(res).toBeInstanceOf(Markup);
});
test("htmlEscape correctly escapes various links", () => {
expect(htmlEscape("<a>this is a link</a>").toString()).toBe(
"&lt;a&gt;this is a link&lt;/a&gt;"
);
expect(htmlEscape(`<a href="https://www.odoo.com">odoo<a>`).toString()).toBe(
`&lt;a href=&quot;https://www.odoo.com&quot;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape(`<a href='https://www.odoo.com'>odoo<a>`).toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;odoo&lt;a&gt;`
);
expect(htmlEscape("<a href='https://www.odoo.com'>Odoo`s website<a>").toString()).toBe(
`&lt;a href=&#x27;https://www.odoo.com&#x27;&gt;Odoo&#x60;s website&lt;a&gt;`
);
});
test("htmlEscape doesn't escape already escaped content", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res).toBeInstanceOf(Markup);
const res2 = htmlEscape(res);
expect(res2.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
expect(res2).toBeInstanceOf(Markup);
expect(res2).toBe(res);
});
test("htmlEscape returns markup even for only-safe text", () => {
const res = htmlEscape("safe");
expect(res.toString()).toBe("safe");
expect(res).toBeInstanceOf(Markup);
});
});
describe("tag function", () => {
test("interpolated values are escaped", () => {
const maliciousInput = "<script>alert('💥💥')</script>";
const html = markup`<b>${maliciousInput}</b>`;
expect(html.toString()).toBe("<b>&lt;script&gt;alert(&#x27;💥💥&#x27;)&lt;/script&gt;</b>");
expect(html).toBeInstanceOf(Markup);
});
test("interpolated markups aren't escaped", () => {
const shouldBeEscaped = "<script>alert('should be escaped')</script>";
const shouldnt = markup("<b>this is safe</b>");
const html = markup`<div>${shouldBeEscaped} ${shouldnt}</div>`;
expect(html.toString()).toBe(
"<div>&lt;script&gt;alert(&#x27;should be escaped&#x27;)&lt;/script&gt; <b>this is safe</b></div>"
);
expect(html).toBeInstanceOf(Markup);
});
test("quotes in interpolated values are escaped", () => {
const imgUrl = `lol" onerror="alert('xss')`;
const html = markup`<img src="${imgUrl}">`;
expect(html.toString()).toBe(`<img src="lol&quot; onerror=&quot;alert(&#x27;xss&#x27;)">`);
});
test("already escaped content is not escaped again", () => {
const res = htmlEscape("<p>test</p>");
expect(res.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
const html = markup`${res}`;
expect(html.toString()).toBe("&lt;p&gt;test&lt;/p&gt;");
});
});
});