Compare commits

..

19 Commits

Author SHA1 Message Date
Romeo Fragomeli cd9b72158b [REL] v2.5.1
# v2.5.1

 - [FIX] compiler: multiple modifiers on t-custom
2024-11-26 09:42:49 +01:00
Jorge Pinna Puissant 7fc552e2f8 [FIX] compiler: multiple modifiers on t-custom
Since [1] it's possible to have a custom directive, this directive only
supported one modifier. This commit, add the support to have multiple
modifiers on a t-custom directive.

[1] : https://github.com/odoo/owl/commit/7e687234bf40bdcf5e6598401d8604d66b14ad32
2024-11-25 16:14:56 +01:00
Romeo Fragomeli e6768501cd [REL] v2.5.0
# v2.5.0

 - [IMP] app: add global values at compile time
 - [IMP] parser: add support for custom directives
 - [FIX] runtime: properly handle error caused in mounted hook
2024-11-25 10:30:53 +01:00
Jorge Pinna Puissant 6b2486473f [IMP] app: add global values at compile time
This commit, add a new configuration on the App: globalValues.
It's a global object of elements available at compilations.

For instance:
```js
    const app = new App(SomeComponent, {
      globalValues: {
        plop: (string: any) => {
          steps.push(string);
        },
      },
    });
```

The plop function will be available at the compilation, so it can be
used on the templates :
```xml
<div t-on-click="() => __globals__.plop('click')" class="my-div"/>
```
2024-11-25 09:57:54 +01:00
Jorge Pinna Puissant 7e687234bf [IMP] parser: add support for custom directives
This commit adds the support for custom directives. To use the
custom directive, an Object of functions needs to be configured on
the owl APP:
```js
 new App(..., {
    customDirectives: {
     test_directive: function (el, value) {
            el.setAttribute("t-on-click", value);
            return el;
       }
   }
  });
```
The functions will be called when a custom directive with the name of the
function is found. The original element will be replaced with the one
returned by the function.
This :
```xml
<div t-custom-test_directive="click" />
```
will be replace by :
```xml
<div t-on-click="value"/>
```

issue : https://github.com/odoo/owl/issues/1650
2024-11-25 09:57:54 +01:00
Géry Debongnie 968e96ad08 [FIX] runtime: properly handle error caused in mounted hook
If a component onMounted hook causes an error, and an error handler
tries to reinitiate a new correct render, the fiber may be recycled. But
the scheduler removes it from its own set of tasks, so it will be
ignored.

We simply check for this condition before removing it from the tasks
set.
2024-11-25 09:56:34 +01:00
Romeo Fragomeli 26c7856d5d [REL] v2.4.1
# v2.4.1

 - [FIX] app: make subroots more robust
 - [IMP] devtools: show objects class name and add special extension cases
 - [IMP] devtools: allow expansion of empty class
 - [IMP] Bump support for Node 20+
2024-10-31 10:42:38 +01:00
Géry Debongnie b8d09e523d [FIX] app: make subroots more robust
This commit fixes two issues with subroots:

1. creating a subroot create a new component node synchronously. This
   would causes issues if the creation was done in the setup of a
component, since in that case, owl would reset the current component to
null right after, which would cause all calls to hooks to fail. This is
fixed by restoring the previous component node right after creating a
root.

2. the destroy method for roots calls the scheduler processTasks.
   However, the processTasks method was not safe to reentrant calls,
which would in some cases crashes owl. For example, if a destroy is done
while a new component is mounted, the mount method would be called
twice.

This is fixed by ignoring the processTasks if we are currently
processing tasks. It works because the "for ... of" loop will still
process all new tasks in the current iteration.
2024-10-21 14:28:27 +02:00
Julien Carion (juca) 04c2808701 [IMP] devtools: show objects class name and add special extension cases
This commit ensures that class names are displayed for non generic
objects and extensions of generic classes will have custom display for
their content.
2024-10-11 10:39:50 +02:00
Julien Carion (juca) 15c2604df1 [IMP] devtools: allow expansion of empty class
This commit makes it possible to expand empty classes to see their
prototype instead of displaying them as simple empty objects.
2024-10-11 10:39:50 +02:00
Romeo Fragomeli 3e11fe6b12 [IMP] Bump support for Node 20+
This commit bump the support for Node 20+.
2024-10-09 20:31:31 +02:00
Romeo Fragomeli 20c6cacb4e [REL] v2.4.0
# v2.4.0

 - [IMP] owl: add basic support for sub roots
 - [IMP] make set of timeout-able hooks (and their timeouts) clearer by using a const map
 - [IMP] devtools: add support for file urls on chrome
2024-09-30 10:49:39 +02:00
Géry Debongnie eb2b32ab60 [IMP] owl: add basic support for sub roots
In this commit, we extend the owl App class to support multiple sub
roots. This is useful for situations where we want to mount sub
components in non-managed DOM. This is exactly what the Knowledge app is
doing, with mounting views in an html editor.

Currently, this requires some difficult and fragile hacks, and still,
the result is that it is very easy to mix components from the main App
and a SubApp.  But Knowledge does not actually care about creating a sub
app. It only needs the possibility to mount sub components in dynamic
places.

closes #1640
2024-09-27 15:35:37 -04:00
Xavier Morel 2a223288d4 [IMP] make set of timeout-able hooks (and their timeouts) clearer by using a const map
Also unnest the handling of `result` via guard clauses, and generate
messages as close as possible to use site, keeping the error
construction itself where it currently is as the goal is specifically
to point back to the *definition* site for the hook function.
2024-09-23 20:29:20 +02:00
Julien Carion (juca) 1272278225 [IMP] devtools: add support for file urls on chrome
This commit ensures that the hook will properly be loaded on file urls
when using chrome (this was already working in firefox). It is still
necessary to check the "Allow access to file URLs" option in the
extensions manager for this to work properly.
2024-09-18 16:24:53 +02:00
Aaron Bohy f502dd732e [REL] v2.3.1
# v2.3.1

 - [FIX] runtime: log if willStart takes more than 3s
2024-08-14 16:25:32 +02:00
Aaron Bohy 9c2d957525 [FIX] runtime: log if willStart takes more than 3s
Before this commit, when willStart/willUpdateProps took more than
3s, a console.warn was done. In odoo, when a warning is logged
during a test, the test fails and the build is considered as "in
error".

There's a component that loads several resources (sequentially) in
its onWillStart, which *sometimes* takes more than 3s, making
builds fail non deterministically. Since a recent change (which
adds another call in the problematic onWillStart), the warning gets
logged quite often.

A quick fix is necessary, so we change the warn into a log, which
won't make build fail.

We may consider alternatives in the future though:
 - add a parameter to onWillStart, to disable the timeout, or to
   specify the delay (which is 3s by default)
 - do not warn in test mode
2024-08-14 15:48:49 +02:00
Romeo Fragomeli f8bb86820e [REL] v2.3.0
# v2.3.0

 - [IMP] compiler: add support for the .translate suffix
2024-07-26 10:36:12 +02:00
Samuel Degueldre 0cde4b8737 [IMP] compiler: add support for the .translate suffix
Previously, if you wanted to pass a prop and have it be translated, you
had to either to the translation manually in JS, or use a workaround
with t-set and a body so that Owl would translate it for you, and then
pass the t-set variable as a prop. This is quite inconvenient and is a
common use case.

This commit introduces the `.translate` suffix to solve this issue. When
a prop uses this suffix, it is treated as a string instead of a JS
expression, avoiding the need for quotes as well as their escaping and
allowing extraction tools such as babel to generate a clean string as
the term's translation id. This is also more ergonomic. This suffix is
available for both component props and slot props.

This change will still require some work in Odoo to correctly extract
the terms for props using this suffix.
2024-07-15 10:14:08 +02:00
40 changed files with 1395 additions and 350 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [12.x, 14.x, 16.x] node-version: [20.x, 22.x]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
+31
View File
@@ -6,6 +6,7 @@
- [API](#api) - [API](#api)
- [Configuration](#configuration) - [Configuration](#configuration)
- [`mount` helper](#mount-helper) - [`mount` helper](#mount-helper)
- [Roots](#roots)
- [Loading templates](#loading-templates) - [Loading templates](#loading-templates)
## Overview ## Overview
@@ -65,6 +66,9 @@ The `config` object is an object with some of the following keys:
needs a template. If undefined is returned, owl looks into the app templates. needs a template. If undefined is returned, owl looks into the app templates.
- **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning - **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning
whenever it encounters a component that does not provide a [static props description](props.md#props-validation). whenever it encounters a component that does not provide a [static props description](props.md#props-validation).
- **`customDirectives (object)`**: if given, the corresponding function on the object will be called
on the template custom directives: `t-custom-*` (see [Custom Directives](templates.md#custom-directives)).
- **`globalValues (object)`**: Global object of elements available at compilations.
## `mount` helper ## `mount` helper
@@ -92,6 +96,33 @@ Most of the time, the `mount` helper is more convenient, but whenever one needs
a reference to the actual Owl App, then using the `App` class directly is a reference to the actual Owl App, then using the `App` class directly is
possible. possible.
## Roots
An application can have multiple roots. It is sometimes useful to instantiate
sub components in places that are not managed by Owl, such as an html editor
with dynamic content (the Knowledge application in Odoo).
To create a root, one can use the `createRoot` method, which takes two arguments:
- **`Component`**: a component class (Root component of the app)
- **`config (optional)`**: a config object that may contain a `props` object or a
`env` object.
The `createRoot` method returns an object with a `mount` method (same API as
the `App.mount` method), and a `destroy` method.
```js
const root = app.createRoot(MyComponent, { props: { someProps: true } });
await root.mount(targetElement);
// later
root.destroy();
```
Note that, like with owl `App`, it is the responsibility of the code that created
the root to properly destroy it (before it has been removed from the DOM!). Owl
has no way of doing it itself.
## Loading templates ## Loading templates
Most applications will need to load templates whenever they start. Here is Most applications will need to load templates whenever they start. Here is
+22
View File
@@ -140,6 +140,28 @@ class SomeComponent extends Component {
The `.bind` suffix also implies `.alike`, so these props will not cause additional The `.bind` suffix also implies `.alike`, so these props will not cause additional
renderings. renderings.
## Translatable props
When you need to pass a user-facing string to a subcomponent, you likely want it
to be translated. Unfortunately, because props are arbitrary expressions, it wouldn't
be practical for Owl to find out which parts of the expression are strings and translate
them, and it also makes it difficult for tooling to extract these strings to generate
terms to translate. While you can work around this issue by doing the translation in
JavaScript, or by using `t-set` with a body (the body of `t-set` is translated),
and passing the variable as a prop, this is a sufficiently common use case that Owl
provides a suffix for this purpose: `.translate`.
```xml
<t t-name="ParentComponent">
<Child someProp.translate="some message"/>
</t>
```
Note that the content of this attribute is _NOT_ treated as a JavaScript expression:
it is treated as a string, as if it was an attribute on an HTML element, and translated
before being passed to the component. If you need to interpolate some data into the
string, you will still have to do this in JavaScript.
## Dynamic Props ## Dynamic Props
The `t-props` directive can be used to specify totally dynamic props: The `t-props` directive can be used to specify totally dynamic props:
+4 -3
View File
@@ -201,16 +201,17 @@ use this `Notebook` component:
```xml ```xml
<Notebook> <Notebook>
<t t-set-slot="page1" title="'Page 1'"> <t t-set-slot="page1" title.translate="Page 1">
<div>this is in the page 1</div> <div>this is in the page 1</div>
</t> </t>
<t t-set-slot="page2" title="'Page 2'" hidden="somevalue"> <t t-set-slot="page2" title.translate="Page 2" hidden="somevalue">
<div>this is in the page 2</div> <div>this is in the page 2</div>
</t> </t>
</Notebook> </Notebook>
``` ```
Slot params works like normal props, so one can use the `.bind` suffix to Slot params works like normal props, so one can use suffixes like `.translate`
when a prop is a user facing string and should be translated, or `.bind` to
bind a function if needed. bind a function if needed.
## Slot scopes ## Slot scopes
+31
View File
@@ -18,6 +18,7 @@
- [Sub Templates](#sub-templates) - [Sub Templates](#sub-templates)
- [Dynamic Sub Templates](#dynamic-sub-templates) - [Dynamic Sub Templates](#dynamic-sub-templates)
- [Debugging](#debugging) - [Debugging](#debugging)
- [Custom Directives](#custom-directives)
- [Fragments](#fragments) - [Fragments](#fragments)
- [Inline templates](#inline-templates) - [Inline templates](#inline-templates)
- [Rendering svg](#rendering-svg) - [Rendering svg](#rendering-svg)
@@ -80,6 +81,7 @@ needs. Here is a list of all Owl specific directives:
| `t-slot`, `t-set-slot`, `t-slot-scope` | [Rendering a slot](slots.md) | | `t-slot`, `t-set-slot`, `t-slot-scope` | [Rendering a slot](slots.md) |
| `t-model` | [Form input bindings](input_bindings.md) | | `t-model` | [Form input bindings](input_bindings.md) |
| `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) | | `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) |
| `t-custom-*` | [Rendering nodes with custom directives](#custom-directives) |
## QWeb Template Reference ## QWeb Template Reference
@@ -588,6 +590,35 @@ will stop execution if the browser dev tools are open.
will print 42 to the console. will print 42 to the console.
### Custom Directives
Owl 2 supports the declaration of custom directives. To use them, an Object of functions needs to be configured on the owl APP:
```js
new App(..., {
customDirectives: {
test_directive: function (el, value) {
el.setAttribute("t-on-click", value);
}
}
});
```
The functions will be called when a custom directive with the name of the
function is found. The original element will be replaced with the one
modified by the function.
This :
```xml
<div t-custom-test_directive="click" />
```
will be replaced by :
```xml
<div t-on-click="value"/>
```
## Fragments ## Fragments
Owl 2 supports templates with an arbitrary number of root elements, or even just Owl 2 supports templates with an arbitrary number of root elements, or even just
+153 -46
View File
@@ -2270,6 +2270,12 @@ function collectionsProxyHandler(target, callback, targetRawType) {
} }
let currentNode = null; let currentNode = null;
function saveCurrent() {
let n = currentNode;
return () => {
currentNode = n;
};
}
function getCurrent() { function getCurrent() {
if (!currentNode) { if (!currentNode) {
throw new OwlError("No active component (a hook function should only be called in 'setup')"); throw new OwlError("No active component (a hook function should only be called in 'setup')");
@@ -2598,42 +2604,47 @@ class ComponentNode {
} }
const TIMEOUT = Symbol("timeout"); const TIMEOUT = Symbol("timeout");
const HOOK_TIMEOUT = {
onWillStart: 3000,
onWillUpdateProps: 3000,
};
function wrapError(fn, hookName) { function wrapError(fn, hookName) {
const error = new OwlError(`The following error occurred in ${hookName}: `); const error = new OwlError();
const timeoutError = new OwlError(`${hookName}'s promise hasn't resolved after 3 seconds`); const timeoutError = new OwlError();
const node = getCurrent(); const node = getCurrent();
return (...args) => { return (...args) => {
const onError = (cause) => { const onError = (cause) => {
error.cause = cause; error.cause = cause;
if (cause instanceof Error) { error.message =
error.message += `"${cause.message}"`; cause instanceof Error
} ? `The following error occurred in ${hookName}: "${cause.message}"`
else { : `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
error.message = `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
}
throw error; throw error;
}; };
let result;
try { try {
const result = fn(...args); result = fn(...args);
if (result instanceof Promise) {
if (hookName === "onWillStart" || hookName === "onWillUpdateProps") {
const fiber = node.fiber;
Promise.race([
result.catch(() => { }),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
console.warn(timeoutError);
}
});
}
return result.catch(onError);
}
return result;
} }
catch (cause) { catch (cause) {
onError(cause); onError(cause);
} }
if (!(result instanceof Promise)) {
return result;
}
const timeout = HOOK_TIMEOUT[hookName];
if (timeout) {
const fiber = node.fiber;
Promise.race([
result.catch(() => { }),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
timeoutError.message = `${hookName}'s promise hasn't resolved after ${timeout / 1000} seconds`;
console.log(timeoutError);
}
});
}
return result.catch(onError);
}; };
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -3216,6 +3227,9 @@ class TemplateSet {
} }
} }
this.getRawTemplate = config.getTemplate; this.getRawTemplate = config.getTemplate;
this.customDirectives = config.customDirectives || {};
this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} };
this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length);
} }
static registerTemplate(name, fn) { static registerTemplate(name, fn) {
globalTemplates[name] = fn; globalTemplates[name] = fn;
@@ -3272,7 +3286,7 @@ class TemplateSet {
this.templates[name] = function (context, parent) { this.templates[name] = function (context, parent) {
return templates[name].call(this, context, parent); return templates[name].call(this, context, parent);
}; };
const template = templateFn(this, bdom, helpers); const template = templateFn(this, bdom, this.runtimeUtils);
this.templates[name] = template; this.templates[name] = template;
} }
return this.templates[name]; return this.templates[name];
@@ -3323,7 +3337,7 @@ TemplateSet.registerTemplate("__portal__", portalTemplate);
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// Misc types, constants and helpers // Misc types, constants and helpers
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date".split(","); const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(",");
const WORD_REPLACEMENT = Object.assign(Object.create(null), { const WORD_REPLACEMENT = Object.assign(Object.create(null), {
and: "&&", and: "&&",
or: "||", or: "||",
@@ -3795,6 +3809,9 @@ class CodeGenerator {
this.dev = options.dev || false; this.dev = options.dev || false;
this.ast = ast; this.ast = ast;
this.templateName = options.name; this.templateName = options.name;
if (options.hasGlobalValues) {
this.helpers.add("__globals__");
}
} }
generateCode() { generateCode() {
const ast = this.ast; const ast = this.ast;
@@ -4594,7 +4611,12 @@ class CodeGenerator {
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/ */
formatProp(name, value) { formatProp(name, value) {
value = this.captureExpression(value); if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value));
}
else {
value = this.captureExpression(value);
}
if (name.includes(".")) { if (name.includes(".")) {
let [_name, suffix] = name.split("."); let [_name, suffix] = name.split(".");
name = _name; name = _name;
@@ -4603,6 +4625,7 @@ class CodeGenerator {
value = `(${value}).bind(this)`; value = `(${value}).bind(this)`;
break; break;
case "alike": case "alike":
case "translate":
break; break;
default: default:
throw new OwlError("Invalid prop suffix"); throw new OwlError("Invalid prop suffix");
@@ -4827,29 +4850,33 @@ class CodeGenerator {
// Parser // Parser
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const cache = new WeakMap(); const cache = new WeakMap();
function parse(xml) { function parse(xml, customDir) {
const ctx = {
inPreTag: false,
customDirectives: customDir,
};
if (typeof xml === "string") { if (typeof xml === "string") {
const elem = parseXML(`<t>${xml}</t>`).firstChild; const elem = parseXML(`<t>${xml}</t>`).firstChild;
return _parse(elem); return _parse(elem, ctx);
} }
let ast = cache.get(xml); let ast = cache.get(xml);
if (!ast) { if (!ast) {
// we clone here the xml to prevent modifying it in place // we clone here the xml to prevent modifying it in place
ast = _parse(xml.cloneNode(true)); ast = _parse(xml.cloneNode(true), ctx);
cache.set(xml, ast); cache.set(xml, ast);
} }
return ast; return ast;
} }
function _parse(xml) { function _parse(xml, ctx) {
normalizeXML(xml); normalizeXML(xml);
const ctx = { inPreTag: false };
return parseNode(xml, ctx) || { type: 0 /* Text */, value: "" }; return parseNode(xml, ctx) || { type: 0 /* Text */, value: "" };
} }
function parseNode(node, ctx) { function parseNode(node, ctx) {
if (!(node instanceof Element)) { if (!(node instanceof Element)) {
return parseTextCommentNode(node, ctx); return parseTextCommentNode(node, ctx);
} }
return (parseTDebugLog(node, ctx) || return (parseTCustom(node, ctx) ||
parseTDebugLog(node, ctx) ||
parseTForEach(node, ctx) || parseTForEach(node, ctx) ||
parseTIf(node, ctx) || parseTIf(node, ctx) ||
parseTPortal(node, ctx) || parseTPortal(node, ctx) ||
@@ -4891,6 +4918,35 @@ function parseTextCommentNode(node, ctx) {
} }
return null; return null;
} }
function parseTCustom(node, ctx) {
if (!ctx.customDirectives) {
return null;
}
const nodeAttrsNames = node.getAttributeNames();
for (let attr of nodeAttrsNames) {
if (attr === "t-custom" || attr === "t-custom-") {
throw new OwlError("Missing custom directive name with t-custom directive");
}
if (attr.startsWith("t-custom-")) {
const directiveName = attr.split(".")[0].slice(9);
const customDirective = ctx.customDirectives[directiveName];
if (!customDirective) {
throw new OwlError(`Custom directive "${directiveName}" is not defined`);
}
const value = node.getAttribute(attr);
const modifiers = attr.split(".").slice(1);
node.removeAttribute(attr);
try {
customDirective(node, value, modifiers);
}
catch (error) {
throw new OwlError(`Custom directive "${directiveName}" throw the following error: ${error}`);
}
return parseNode(node, ctx);
}
}
return null;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// debugging // debugging
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -5522,9 +5578,11 @@ function normalizeXML(el) {
normalizeTEscTOut(el); normalizeTEscTOut(el);
} }
function compile(template, options = {}) { function compile(template, options = {
hasGlobalValues: false,
}) {
// parsing // parsing
const ast = parse(template); const ast = parse(template, options.customDirectives);
// some work // some work
const hasSafeContext = template instanceof Node const hasSafeContext = template instanceof Node
? !(template instanceof Element) || template.querySelector("[t-set], [t-call]") === null ? !(template instanceof Element) || template.querySelector("[t-set], [t-call]") === null
@@ -5546,7 +5604,7 @@ function compile(template, options = {}) {
} }
// do not modify manually. This file is generated by the release script. // do not modify manually. This file is generated by the release script.
const version = "2.2.11"; const version = "2.5.1";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Scheduler // Scheduler
@@ -5557,6 +5615,7 @@ class Scheduler {
this.frame = 0; this.frame = 0;
this.delayedRenders = []; this.delayedRenders = [];
this.cancelledNodes = new Set(); this.cancelledNodes = new Set();
this.processing = false;
this.requestAnimationFrame = Scheduler.requestAnimationFrame; this.requestAnimationFrame = Scheduler.requestAnimationFrame;
} }
addFiber(fiber) { addFiber(fiber) {
@@ -5587,6 +5646,10 @@ class Scheduler {
} }
} }
processTasks() { processTasks() {
if (this.processing) {
return;
}
this.processing = true;
this.frame = 0; this.frame = 0;
for (let node of this.cancelledNodes) { for (let node of this.cancelledNodes) {
node._destroy(); node._destroy();
@@ -5600,6 +5663,7 @@ class Scheduler {
this.tasks.delete(task); this.tasks.delete(task);
} }
} }
this.processing = false;
} }
processFiber(fiber) { processFiber(fiber) {
if (fiber.root !== fiber) { if (fiber.root !== fiber) {
@@ -5619,7 +5683,14 @@ class Scheduler {
if (!hasError) { if (!hasError) {
fiber.complete(); fiber.complete();
} }
this.tasks.delete(fiber); // at this point, the fiber should have been applied to the DOM, so we can
// remove it from the task list. If it is not the case, it means that there
// was an error and an error handler triggered a new rendering that recycled
// the fiber, so in that case, we actually want to keep the fiber around,
// otherwise it will just be ignored.
if (fiber.appliedToDom) {
this.tasks.delete(fiber);
}
} }
} }
} }
@@ -5641,6 +5712,7 @@ class App extends TemplateSet {
constructor(Root, config = {}) { constructor(Root, config = {}) {
super(config); super(config);
this.scheduler = new Scheduler(); this.scheduler = new Scheduler();
this.subRoots = new Set();
this.root = null; this.root = null;
this.name = config.name || ""; this.name = config.name || "";
this.Root = Root; this.Root = Root;
@@ -5659,14 +5731,44 @@ class App extends TemplateSet {
this.props = config.props || {}; this.props = config.props || {};
} }
mount(target, options) { mount(target, options) {
App.validateTarget(target); const root = this.createRoot(this.Root, { props: this.props });
if (this.dev) { this.root = root.node;
validateProps(this.Root, this.props, { __owl__: { app: this } }); this.subRoots.delete(root.node);
return root.mount(target, options);
}
createRoot(Root, config = {}) {
const props = config.props || {};
// hack to make sure the sub root get the sub env if necessary. for owl 3,
// would be nice to rethink the initialization process to make sure that
// we can create a ComponentNode and give it explicitely the env, instead
// of looking it up in the app
const env = this.env;
if (config.env) {
this.env = config.env;
} }
const node = this.makeNode(this.Root, this.props); const restore = saveCurrent();
const prom = this.mountNode(node, target, options); const node = this.makeNode(Root, props);
this.root = node; restore();
return prom; if (config.env) {
this.env = env;
}
this.subRoots.add(node);
return {
node,
mount: (target, options) => {
App.validateTarget(target);
if (this.dev) {
validateProps(Root, props, { __owl__: { app: this } });
}
const prom = this.mountNode(node, target, options);
return prom;
},
destroy: () => {
this.subRoots.delete(node);
node.destroy();
this.scheduler.processTasks();
},
};
} }
makeNode(Component, props) { makeNode(Component, props) {
return new ComponentNode(Component, props, this, null, null); return new ComponentNode(Component, props, this, null, null);
@@ -5698,6 +5800,9 @@ class App extends TemplateSet {
} }
destroy() { destroy() {
if (this.root) { if (this.root) {
for (let subroot of this.subRoots) {
subroot.destroy();
}
this.root.destroy(); this.root.destroy();
this.scheduler.processTasks(); this.scheduler.processTasks();
} }
@@ -5969,12 +6074,14 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
dev: this.dev, dev: this.dev,
translateFn: this.translateFn, translateFn: this.translateFn,
translatableAttributes: this.translatableAttributes, translatableAttributes: this.translatableAttributes,
customDirectives: this.customDirectives,
hasGlobalValues: this.hasGlobalValues,
}); });
}; };
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, 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 = '2024-06-17T13:31:12.099Z'; __info__.date = '2024-11-26T08:42:41.633Z';
__info__.hash = 'e7f405c'; __info__.hash = '7fc552e';
__info__.url = 'https://github.com/odoo/owl'; __info__.url = 'https://github.com/odoo/owl';
+32 -207
View File
@@ -1,6 +1,6 @@
{ {
"name": "@odoo/owl", "name": "@odoo/owl",
"version": "2.2.11", "version": "2.5.1",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -127,6 +127,25 @@
"integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
"dev": true "dev": true
}, },
"@babel/helper-function-name": {
"version": "7.21.0",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz",
"integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==",
"dev": true,
"requires": {
"@babel/template": "^7.20.7",
"@babel/types": "^7.21.0"
}
},
"@babel/helper-hoist-variables": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
"integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
"dev": true,
"requires": {
"@babel/types": "^7.18.6"
}
},
"@babel/helper-module-imports": { "@babel/helper-module-imports": {
"version": "7.18.6", "version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
@@ -409,222 +428,28 @@
} }
}, },
"@babel/traverse": { "@babel/traverse": {
"version": "7.24.7", "version": "7.21.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.3.tgz",
"integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", "integrity": "sha512-XLyopNeaTancVitYZe2MlUEvgKb6YVVPXzofHgqHijCImG33b/uTurMS488ht/Hbsb2XK3U2BnSTxKVNGV3nGQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/code-frame": "^7.24.7", "@babel/code-frame": "^7.18.6",
"@babel/generator": "^7.24.7", "@babel/generator": "^7.21.3",
"@babel/helper-environment-visitor": "^7.24.7", "@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.24.7", "@babel/helper-function-name": "^7.21.0",
"@babel/helper-hoist-variables": "^7.24.7", "@babel/helper-hoist-variables": "^7.18.6",
"@babel/helper-split-export-declaration": "^7.24.7", "@babel/helper-split-export-declaration": "^7.18.6",
"@babel/parser": "^7.24.7", "@babel/parser": "^7.21.3",
"@babel/types": "^7.24.7", "@babel/types": "^7.21.3",
"debug": "^4.3.1", "debug": "^4.1.0",
"globals": "^11.1.0" "globals": "^11.1.0"
}, },
"dependencies": { "dependencies": {
"@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
"dev": true,
"requires": {
"@babel/highlight": "^7.24.7",
"picocolors": "^1.0.0"
}
},
"@babel/generator": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz",
"integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==",
"dev": true,
"requires": {
"@babel/types": "^7.24.7",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^2.5.1"
}
},
"@babel/helper-environment-visitor": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz",
"integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==",
"dev": true,
"requires": {
"@babel/types": "^7.24.7"
}
},
"@babel/helper-function-name": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz",
"integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==",
"dev": true,
"requires": {
"@babel/template": "^7.24.7",
"@babel/types": "^7.24.7"
}
},
"@babel/helper-hoist-variables": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz",
"integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==",
"dev": true,
"requires": {
"@babel/types": "^7.24.7"
}
},
"@babel/helper-split-export-declaration": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz",
"integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==",
"dev": true,
"requires": {
"@babel/types": "^7.24.7"
}
},
"@babel/helper-string-parser": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz",
"integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==",
"dev": true
},
"@babel/helper-validator-identifier": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"dev": true
},
"@babel/highlight": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.24.7",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
}
},
"@babel/parser": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
"integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==",
"dev": true
},
"@babel/template": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
"integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.24.7",
"@babel/parser": "^7.24.7",
"@babel/types": "^7.24.7"
}
},
"@babel/types": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz",
"integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==",
"dev": true,
"requires": {
"@babel/helper-string-parser": "^7.24.7",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
}
},
"@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
"dev": true,
"requires": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true
},
"@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"dev": true,
"requires": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"requires": {
"color-convert": "^1.9.0"
}
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
},
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true
},
"globals": { "globals": {
"version": "11.12.0", "version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true "dev": true
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
} }
} }
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@odoo/owl", "name": "@odoo/owl",
"version": "2.2.11", "version": "2.5.1",
"description": "Odoo Web Library (OWL)", "description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js", "main": "dist/owl.cjs.js",
"module": "dist/owl.es.js", "module": "dist/owl.es.js",
@@ -9,7 +9,7 @@
"dist" "dist"
], ],
"engines": { "engines": {
"node": ">=12.18.3" "node": ">=20.0.0"
}, },
"scripts": { "scripts": {
"build:bundle": "rollup -c --failAfterWarnings", "build:bundle": "rollup -c --failAfterWarnings",
+4
View File
@@ -0,0 +1,4 @@
export type customDirectives = Record<
string,
(node: Element, value: string, modifier: string[]) => void
>;
+10 -1
View File
@@ -43,6 +43,7 @@ export interface Config {
export interface CodeGenOptions extends Config { export interface CodeGenOptions extends Config {
hasSafeContext?: boolean; hasSafeContext?: boolean;
name?: string; name?: string;
hasGlobalValues: boolean;
} }
// using a non-html document so that <inner/outer>HTML serializes as XML instead // using a non-html document so that <inner/outer>HTML serializes as XML instead
@@ -286,6 +287,9 @@ export class CodeGenerator {
this.dev = options.dev || false; this.dev = options.dev || false;
this.ast = ast; this.ast = ast;
this.templateName = options.name; this.templateName = options.name;
if (options.hasGlobalValues) {
this.helpers.add("__globals__");
}
} }
generateCode(): string { generateCode(): string {
@@ -1136,7 +1140,11 @@ export class CodeGenerator {
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/ */
formatProp(name: string, value: string): string { formatProp(name: string, value: string): string {
value = this.captureExpression(value); if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value));
} else {
value = this.captureExpression(value);
}
if (name.includes(".")) { if (name.includes(".")) {
let [_name, suffix] = name.split("."); let [_name, suffix] = name.split(".");
name = _name; name = _name;
@@ -1145,6 +1153,7 @@ export class CodeGenerator {
value = `(${value}).bind(this)`; value = `(${value}).bind(this)`;
break; break;
case "alike": case "alike":
case "translate":
break; break;
default: default:
throw new OwlError("Invalid prop suffix"); throw new OwlError("Invalid prop suffix");
+7 -2
View File
@@ -1,3 +1,4 @@
import type { customDirectives } from "../common/types";
import type { TemplateSet } from "../runtime/template_set"; import type { TemplateSet } from "../runtime/template_set";
import type { BDom } from "../runtime/blockdom"; import type { BDom } from "../runtime/blockdom";
import { CodeGenerator, Config } from "./code_generator"; import { CodeGenerator, Config } from "./code_generator";
@@ -10,13 +11,17 @@ export type TemplateFunction = (app: TemplateSet, bdom: any, helpers: any) => Te
interface CompileOptions extends Config { interface CompileOptions extends Config {
name?: string; name?: string;
customDirectives?: customDirectives;
hasGlobalValues: boolean;
} }
export function compile( export function compile(
template: string | Element, template: string | Element,
options: CompileOptions = {} options: CompileOptions = {
hasGlobalValues: false,
}
): TemplateFunction { ): TemplateFunction {
// parsing // parsing
const ast = parse(template); const ast = parse(template, options.customDirectives);
// some work // some work
const hasSafeContext = const hasSafeContext =
+1 -1
View File
@@ -28,7 +28,7 @@ import { OwlError } from "../common/owl_error";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
const RESERVED_WORDS = const RESERVED_WORDS =
"true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date".split( "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(
"," ","
); );
+42 -5
View File
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
import type { customDirectives } from "../common/types";
import { parseXML } from "../common/utils"; import { parseXML } from "../common/utils";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -198,23 +199,26 @@ export type AST =
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const cache: WeakMap<Element, AST> = new WeakMap(); const cache: WeakMap<Element, AST> = new WeakMap();
export function parse(xml: string | Element): AST { export function parse(xml: string | Element, customDir?: customDirectives): AST {
const ctx = {
inPreTag: false,
customDirectives: customDir,
};
if (typeof xml === "string") { if (typeof xml === "string") {
const elem = parseXML(`<t>${xml}</t>`).firstChild as Element; const elem = parseXML(`<t>${xml}</t>`).firstChild as Element;
return _parse(elem); return _parse(elem, ctx);
} }
let ast = cache.get(xml); let ast = cache.get(xml);
if (!ast) { if (!ast) {
// we clone here the xml to prevent modifying it in place // we clone here the xml to prevent modifying it in place
ast = _parse(xml.cloneNode(true) as Element); ast = _parse(xml.cloneNode(true) as Element, ctx);
cache.set(xml, ast); cache.set(xml, ast);
} }
return ast; return ast;
} }
function _parse(xml: Element): AST { function _parse(xml: Element, ctx: ParsingContext): AST {
normalizeXML(xml); normalizeXML(xml);
const ctx = { inPreTag: false };
return parseNode(xml, ctx) || { type: ASTType.Text, value: "" }; return parseNode(xml, ctx) || { type: ASTType.Text, value: "" };
} }
@@ -222,6 +226,7 @@ interface ParsingContext {
tModelInfo?: TModelInfo | null; tModelInfo?: TModelInfo | null;
nameSpace?: string; nameSpace?: string;
inPreTag: boolean; inPreTag: boolean;
customDirectives?: customDirectives;
} }
function parseNode(node: Node, ctx: ParsingContext): AST | null { function parseNode(node: Node, ctx: ParsingContext): AST | null {
@@ -229,6 +234,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
return parseTextCommentNode(node, ctx); return parseTextCommentNode(node, ctx);
} }
return ( return (
parseTCustom(node, ctx) ||
parseTDebugLog(node, ctx) || parseTDebugLog(node, ctx) ||
parseTForEach(node, ctx) || parseTForEach(node, ctx) ||
parseTIf(node, ctx) || parseTIf(node, ctx) ||
@@ -277,6 +283,37 @@ function parseTextCommentNode(node: Node, ctx: ParsingContext): AST | null {
return null; return null;
} }
function parseTCustom(node: Element, ctx: ParsingContext): AST | null {
if (!ctx.customDirectives) {
return null;
}
const nodeAttrsNames = node.getAttributeNames();
for (let attr of nodeAttrsNames) {
if (attr === "t-custom" || attr === "t-custom-") {
throw new OwlError("Missing custom directive name with t-custom directive");
}
if (attr.startsWith("t-custom-")) {
const directiveName = attr.split(".")[0].slice(9);
const customDirective = ctx.customDirectives[directiveName];
if (!customDirective) {
throw new OwlError(`Custom directive "${directiveName}" is not defined`);
}
const value = node.getAttribute(attr)!;
const modifiers = attr.split(".").slice(1);
node.removeAttribute(attr);
try {
customDirective(node, value, modifiers);
} catch (error) {
throw new OwlError(
`Custom directive "${directiveName}" throw the following error: ${error}`
);
}
return parseNode(node, ctx);
}
}
return null;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// debugging // debugging
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+2
View File
@@ -12,5 +12,7 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(
dev: this.dev, dev: this.dev,
translateFn: this.translateFn, translateFn: this.translateFn,
translatableAttributes: this.translatableAttributes, translatableAttributes: this.translatableAttributes,
customDirectives: this.customDirectives,
hasGlobalValues: this.hasGlobalValues,
}); });
}; };
+58 -10
View File
@@ -1,6 +1,6 @@
import { version } from "../version"; import { version } from "../version";
import { Component, ComponentConstructor, Props } from "./component"; import { Component, ComponentConstructor, Props } from "./component";
import { ComponentNode } from "./component_node"; import { ComponentNode, saveCurrent } from "./component_node";
import { nodeErrorHandlers, handleError } from "./error_handling"; import { nodeErrorHandlers, handleError } from "./error_handling";
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
import { Fiber, RootFiber, MountOptions } from "./fibers"; import { Fiber, RootFiber, MountOptions } from "./fibers";
@@ -16,10 +16,13 @@ export interface Env {
[key: string]: any; [key: string]: any;
} }
export interface AppConfig<P, E> extends TemplateSetConfig { export interface RootConfig<P, E> {
name?: string;
props?: P; props?: P;
env?: E; env?: E;
}
export interface AppConfig<P, E> extends TemplateSetConfig, RootConfig<P, E> {
name?: string;
test?: boolean; test?: boolean;
warnIfNoStaticProps?: boolean; warnIfNoStaticProps?: boolean;
} }
@@ -49,6 +52,12 @@ declare global {
} }
} }
interface Root<P extends Props, E> {
node: ComponentNode<P, E>;
mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
destroy(): void;
}
window.__OWL_DEVTOOLS__ ||= { apps, Fiber, RootFiber, toRaw, reactive }; window.__OWL_DEVTOOLS__ ||= { apps, Fiber, RootFiber, toRaw, reactive };
export class App< export class App<
@@ -65,6 +74,7 @@ export class App<
props: P; props: P;
env: E; env: E;
scheduler = new Scheduler(); scheduler = new Scheduler();
subRoots: Set<ComponentNode> = new Set();
root: ComponentNode<P, E> | null = null; root: ComponentNode<P, E> | null = null;
warnIfNoStaticProps: boolean; warnIfNoStaticProps: boolean;
@@ -91,14 +101,49 @@ export class App<
target: HTMLElement | ShadowRoot, target: HTMLElement | ShadowRoot,
options?: MountOptions options?: MountOptions
): Promise<Component<P, E> & InstanceType<T>> { ): Promise<Component<P, E> & InstanceType<T>> {
App.validateTarget(target); const root = this.createRoot(this.Root, { props: this.props });
if (this.dev) { this.root = root.node;
validateProps(this.Root, this.props, { __owl__: { app: this } }); this.subRoots.delete(root.node);
return root.mount(target, options) as any;
}
createRoot<Props extends object, SubEnv = any>(
Root: ComponentConstructor<Props, E>,
config: RootConfig<Props, SubEnv> = {}
): Root<Props, SubEnv> {
const props = config.props || ({} as Props);
// hack to make sure the sub root get the sub env if necessary. for owl 3,
// would be nice to rethink the initialization process to make sure that
// we can create a ComponentNode and give it explicitely the env, instead
// of looking it up in the app
const env = this.env;
if (config.env) {
this.env = config.env as any;
} }
const node = this.makeNode(this.Root, this.props);
const prom = this.mountNode(node, target, options); const restore = saveCurrent();
this.root = node; const node = this.makeNode(Root, props);
return prom; restore();
if (config.env) {
this.env = env;
}
this.subRoots.add(node);
return {
node,
mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => {
App.validateTarget(target);
if (this.dev) {
validateProps(Root, props, { __owl__: { app: this } });
}
const prom = this.mountNode(node, target, options);
return prom;
},
destroy: () => {
this.subRoots.delete(node);
node.destroy();
this.scheduler.processTasks();
},
};
} }
makeNode(Component: ComponentConstructor, props: any): ComponentNode { makeNode(Component: ComponentConstructor, props: any): ComponentNode {
@@ -134,6 +179,9 @@ export class App<
destroy() { destroy() {
if (this.root) { if (this.root) {
for (let subroot of this.subRoots) {
subroot.destroy();
}
this.root.destroy(); this.root.destroy();
this.scheduler.processTasks(); this.scheduler.processTasks();
} }
+7
View File
@@ -10,6 +10,13 @@ import { batched, Callback } from "./utils";
let currentNode: ComponentNode | null = null; let currentNode: ComponentNode | null = null;
export function saveCurrent() {
let n = currentNode;
return () => {
currentNode = n;
};
}
export function getCurrent(): ComponentNode { export function getCurrent(): ComponentNode {
if (!currentNode) { if (!currentNode) {
throw new OwlError("No active component (a hook function should only be called in 'setup')"); throw new OwlError("No active component (a hook function should only be called in 'setup')");
+31 -23
View File
@@ -3,42 +3,50 @@ import { nodeErrorHandlers } from "./error_handling";
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
const TIMEOUT = Symbol("timeout"); const TIMEOUT = Symbol("timeout");
const HOOK_TIMEOUT: { [key: string]: number } = {
onWillStart: 3000,
onWillUpdateProps: 3000,
};
function wrapError(fn: (...args: any[]) => any, hookName: string) { function wrapError(fn: (...args: any[]) => any, hookName: string) {
const error = new OwlError(`The following error occurred in ${hookName}: `) as Error & { const error = new OwlError() as Error & {
cause: any; cause: any;
}; };
const timeoutError = new OwlError(`${hookName}'s promise hasn't resolved after 3 seconds`); const timeoutError = new OwlError();
const node = getCurrent(); const node = getCurrent();
return (...args: any[]) => { return (...args: any[]) => {
const onError = (cause: any) => { const onError = (cause: any) => {
error.cause = cause; error.cause = cause;
if (cause instanceof Error) { error.message =
error.message += `"${cause.message}"`; cause instanceof Error
} else { ? `The following error occurred in ${hookName}: "${cause.message}"`
error.message = `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`; : `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
}
throw error; throw error;
}; };
let result;
try { try {
const result = fn(...args); result = fn(...args);
if (result instanceof Promise) {
if (hookName === "onWillStart" || hookName === "onWillUpdateProps") {
const fiber = node.fiber;
Promise.race([
result.catch(() => {}),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
console.warn(timeoutError);
}
});
}
return result.catch(onError);
}
return result;
} catch (cause) { } catch (cause) {
onError(cause); onError(cause);
} }
if (!(result instanceof Promise)) {
return result;
}
const timeout = HOOK_TIMEOUT[hookName];
if (timeout) {
const fiber = node.fiber;
Promise.race([
result.catch(() => {}),
new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
]).then((res) => {
if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
timeoutError.message = `${hookName}'s promise hasn't resolved after ${
timeout / 1000
} seconds`;
console.log(timeoutError);
}
});
}
return result.catch(onError);
}; };
} }
+14 -1
View File
@@ -16,6 +16,7 @@ export class Scheduler {
frame: number = 0; frame: number = 0;
delayedRenders: Fiber[] = []; delayedRenders: Fiber[] = [];
cancelledNodes: Set<ComponentNode> = new Set(); cancelledNodes: Set<ComponentNode> = new Set();
processing = false;
constructor() { constructor() {
this.requestAnimationFrame = Scheduler.requestAnimationFrame; this.requestAnimationFrame = Scheduler.requestAnimationFrame;
@@ -53,6 +54,10 @@ export class Scheduler {
} }
processTasks() { processTasks() {
if (this.processing) {
return;
}
this.processing = true;
this.frame = 0; this.frame = 0;
for (let node of this.cancelledNodes) { for (let node of this.cancelledNodes) {
node._destroy(); node._destroy();
@@ -66,6 +71,7 @@ export class Scheduler {
this.tasks.delete(task); this.tasks.delete(task);
} }
} }
this.processing = false;
} }
processFiber(fiber: RootFiber) { processFiber(fiber: RootFiber) {
@@ -87,7 +93,14 @@ export class Scheduler {
if (!hasError) { if (!hasError) {
fiber.complete(); fiber.complete();
} }
this.tasks.delete(fiber); // at this point, the fiber should have been applied to the DOM, so we can
// remove it from the task list. If it is not the case, it means that there
// was an error and an error handler triggered a new rendering that recycled
// the fiber, so in that case, we actually want to keep the fiber around,
// otherwise it will just be ignored.
if (fiber.appliedToDom) {
this.tasks.delete(fiber);
}
} }
} }
} }
+10 -1
View File
@@ -5,6 +5,7 @@ import { Portal, portalTemplate } from "./portal";
import { helpers } from "./template_helpers"; import { helpers } from "./template_helpers";
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
import { parseXML } from "../common/utils"; import { parseXML } from "../common/utils";
import type { customDirectives } from "../common/types";
const bdom = { text, createBlock, list, multi, html, toggler, comment }; const bdom = { text, createBlock, list, multi, html, toggler, comment };
@@ -14,6 +15,8 @@ export interface TemplateSetConfig {
translateFn?: (s: string) => string; translateFn?: (s: string) => string;
templates?: string | Document | Record<string, string>; templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void; getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
globalValues?: object;
} }
export class TemplateSet { export class TemplateSet {
@@ -27,6 +30,9 @@ export class TemplateSet {
translateFn?: (s: string) => string; translateFn?: (s: string) => string;
translatableAttributes?: string[]; translatableAttributes?: string[];
Portal = Portal; Portal = Portal;
customDirectives: customDirectives;
runtimeUtils: object;
hasGlobalValues: boolean;
constructor(config: TemplateSetConfig = {}) { constructor(config: TemplateSetConfig = {}) {
this.dev = config.dev || false; this.dev = config.dev || false;
@@ -42,6 +48,9 @@ export class TemplateSet {
} }
} }
this.getRawTemplate = config.getTemplate; this.getRawTemplate = config.getTemplate;
this.customDirectives = config.customDirectives || {};
this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} };
this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length);
} }
addTemplate(name: string, template: string | Element) { addTemplate(name: string, template: string | Element) {
@@ -97,7 +106,7 @@ export class TemplateSet {
this.templates[name] = function (context, parent) { this.templates[name] = function (context, parent) {
return templates[name].call(this, context, parent); return templates[name].call(this, context, parent);
}; };
const template = templateFn(this, bdom, helpers); const template = templateFn(this, bdom, this.runtimeUtils);
this.templates[name] = template; this.templates[name] = template;
} }
return this.templates[name]; return this.templates[name];
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script. // do not modify manually. This file is generated by the release script.
export const version = "2.2.11"; export const version = "2.5.1";
+42
View File
@@ -43,6 +43,48 @@ exports[`app app: clear scheduler tasks and destroy cancelled nodes immediately
}" }"
`; `;
exports[`app can add functions to the bdom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { __globals__ } = helpers;
let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [()=>__globals__.plop('click'), ctx];
return block1([hdlr1]);
}
}"
`;
exports[`app can call processTask twice in a row without crashing 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`parent\`);
const b3 = comp1({}, key + \`__1\`, node, this, null);
return multi([b2, b3]);
}
}"
`;
exports[`app can call processTask twice in a row without crashing 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`app can configure an app with props 1`] = ` exports[`app can configure an app with props 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -0,0 +1,210 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`destroy a subroot while another component is mounted in main app 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`ChildB\`, true, false, false, []);
const comp2 = app.createComponent(\`ChildA\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
let b2, b3;
if (ctx['state'].flag) {
b2 = comp1({}, key + \`__1\`, node, this, null);
} else {
b3 = comp2({}, key + \`__2\`, node, this, null);
}
return multi([b2, b3]);
}
}"
`;
exports[`destroy a subroot while another component is mounted in main app 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block3 = createBlock(\`<div block-ref=\\"0\\"/>\`);
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`a\`);
let ref1 = (el) => this.__owl__.setRef((\`elem\`), el);
const b3 = block3([ref1]);
return multi([b2, b3]);
}
}"
`;
exports[`destroy a subroot while another component is mounted in main app 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`c\`);
}
}"
`;
exports[`destroy a subroot while another component is mounted in main app 4`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`b\`);
}
}"
`;
exports[`subroot by default, env is the same in sub root 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>main app</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot by default, env is the same in sub root 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot can create a root in a setup function, then use a hook 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`a\`);
}
}"
`;
exports[`subroot can create a root in a setup function, then use a hook 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`c\`);
}
}"
`;
exports[`subroot can mount subroot 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>main app</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot can mount subroot 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot can mount subroot inside own dom 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>main app</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot can mount subroot inside own dom 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot env can be specified for sub roots 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>main app</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot env can be specified for sub roots 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>main app</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
+36 -1
View File
@@ -1,4 +1,4 @@
import { App, Component, mount, onWillStart, useState, xml } from "../../src"; import { App, Component, mount, onWillPatch, onWillStart, useState, xml } from "../../src";
import { status } from "../../src/runtime/status"; import { status } from "../../src/runtime/status";
import { import {
makeTestFixture, makeTestFixture,
@@ -184,4 +184,39 @@ describe("app", () => {
expect(Object.keys(app.templates)).toEqual(["hello"]); expect(Object.keys(app.templates)).toEqual(["hello"]);
expect(Object.keys(app.rawTemplates)).toEqual(["hello", "world"]); expect(Object.keys(app.rawTemplates)).toEqual(["hello", "world"]);
}); });
test("can call processTask twice in a row without crashing", async () => {
class Child extends Component {
static template = xml`<div/>`;
setup() {
onWillPatch(() => app.scheduler.processTasks());
}
}
class SomeComponent extends Component {
static template = xml`parent<Child/>`;
static components = { Child };
}
const app = new App(SomeComponent);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("parent<div></div>");
});
test("can add functions to the bdom", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-on-click="() => __globals__.plop('click')" class="my-div"/>`;
}
const app = new App(SomeComponent, {
globalValues: {
plop: (string: any) => {
steps.push(string);
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["click"]);
});
}); });
+176
View File
@@ -0,0 +1,176 @@
import { App, Component, onMounted, onWillDestroy, useRef, useState, xml } from "../../src";
import { status } from "../../src/runtime/status";
import { makeTestFixture, nextTick, snapshotEverything } from "../helpers";
let fixture: HTMLElement;
snapshotEverything();
beforeEach(() => {
fixture = makeTestFixture();
});
class SomeComponent extends Component {
static template = xml`<div>main app</div>`;
}
class SubComponent extends Component {
static template = xml`<div>sub root</div>`;
}
describe("subroot", () => {
test("can mount subroot", async () => {
const app = new App(SomeComponent);
const comp = await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div>");
const subRoot = app.createRoot(SubComponent);
const subcomp = await subRoot.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div><div>sub root</div>");
app.destroy();
expect(fixture.innerHTML).toBe("");
expect(status(comp)).toBe("destroyed");
expect(status(subcomp)).toBe("destroyed");
});
test("can mount subroot inside own dom", async () => {
const app = new App(SomeComponent);
const comp = await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div>");
const subRoot = app.createRoot(SubComponent);
const subcomp = await subRoot.mount(fixture.querySelector("div")!);
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");
app.destroy();
expect(fixture.innerHTML).toBe("");
expect(status(comp)).toBe("destroyed");
expect(status(subcomp)).toBe("destroyed");
});
test("by default, env is the same in sub root", async () => {
let env, subenv;
class SC extends SomeComponent {
setup() {
env = this.env;
}
}
class Sub extends SubComponent {
setup() {
subenv = this.env;
}
}
const app = new App(SC);
await app.mount(fixture);
const subRoot = app.createRoot(Sub);
await subRoot.mount(fixture);
expect(env).toBeDefined();
expect(subenv).toBeDefined();
expect(env).toBe(subenv);
});
test("env can be specified for sub roots", async () => {
const env1 = { env1: true };
const env2 = {};
let someComponentEnv: any, subComponentEnv: any;
class SC extends SomeComponent {
setup() {
someComponentEnv = this.env;
}
}
class Sub extends SubComponent {
setup() {
subComponentEnv = this.env;
}
}
const app = new App(SC, { env: env1 });
await app.mount(fixture);
const subRoot = app.createRoot(Sub, { env: env2 });
await subRoot.mount(fixture);
// because env is different in app => it is given a sub object, frozen and all
// not sure it is a good idea, but it's the way owl 2 works. maybe we should
// avoid doing anything with the main env and let user code do it if they
// want. in that case, we can change the test here to assert that they are equal
expect(someComponentEnv).not.toBe(env1);
expect(someComponentEnv!.env1).toBe(true);
expect(subComponentEnv).toBe(env2);
});
test("subcomponents can be destroyed, and it properly cleanup the subroots", async () => {
const app = new App(SomeComponent);
const comp = await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div>");
const root = app.createRoot(SubComponent);
const subcomp = await root.mount(fixture.querySelector("div")!);
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");
root.destroy();
expect(fixture.innerHTML).toBe("<div>main app</div>");
expect(status(comp)).not.toBe("destroyed");
expect(status(subcomp)).toBe("destroyed");
});
test("can create a root in a setup function, then use a hook", async () => {
class C extends Component {
static template = xml`c`;
}
class A extends Component {
static template = xml`a`;
state: any;
setup() {
app.createRoot(C);
this.state = useState({ value: 1 });
}
}
const app = new App(A);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("a");
});
});
test("destroy a subroot while another component is mounted in main app", async () => {
class C extends Component {
static template = xml`c`;
}
class ChildA extends Component {
static template = xml`a<div t-ref="elem"></div>`;
ref: any;
setup() {
this.ref = useRef("elem");
let root = app.createRoot(C);
onMounted(() => {
root.mount(this.ref.el);
});
onWillDestroy(() => {
root.destroy();
});
}
}
class ChildB extends Component {
static template = xml`b`;
}
class SomeComponent extends Component {
static template = xml`
<t t-if="state.flag"><ChildB/></t>
<t t-else=""><ChildA/></t>
`;
static components = { ChildA, ChildB };
state = useState({ flag: false });
}
const app = new App(SomeComponent);
const comp = await app.mount(fixture);
expect(fixture.innerHTML).toBe("a<div></div>");
await nextTick();
expect(fixture.innerHTML).toBe("a<div>c</div>");
comp.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe("b");
});
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-custom can use t-custom directive on a node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['click'], ctx];
return block1([hdlr1]);
}
}"
`;
exports[`t-custom can use t-custom directive with modifiers on a node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['click'], ctx];
return block1([hdlr1]);
}
}"
`;
+57
View File
@@ -0,0 +1,57 @@
import { App, Component, xml } from "../../src";
import { makeTestFixture, snapshotEverything } from "../helpers";
let fixture: HTMLElement;
snapshotEverything();
beforeEach(() => {
fixture = makeTestFixture();
});
describe("t-custom", () => {
test("can use t-custom directive on a node", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-custom-plop="click" class="my-div"/>`;
click() {
steps.push("clicked");
}
}
const app = new App(SomeComponent, {
customDirectives: {
plop: (node, value) => {
node.setAttribute("t-on-click", value);
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["clicked"]);
});
test("can use t-custom directive with modifiers on a node", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-custom-plop.mouse.stop="click" class="my-div"/>`;
click() {
steps.push("clicked");
}
}
const app = new App(SomeComponent, {
customDirectives: {
plop: (node, value, modifiers) => {
node.setAttribute("t-on-click", value);
for (let mod of modifiers) {
steps.push(mod);
}
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["mouse", "stop", "clicked"]);
});
});
+1 -1
View File
@@ -48,7 +48,7 @@ describe("basic validation", () => {
test("compilation error", () => { test("compilation error", () => {
const template = `<div t-att-class="a b">test</div>`; const template = `<div t-att-class="a b">test</div>`;
expect(() => renderToString(template)) expect(() => renderToString(template))
.toThrow(`Failed to compile anonymous template: Unexpected identifier .toThrow(`Failed to compile anonymous template: Unexpected identifier 'ctx'
generated code: generated code:
function(app, bdom, helpers) { function(app, bdom, helpers) {
@@ -97,6 +97,19 @@ exports[`basics a component cannot be mounted in a detached node (even if node i
}" }"
`; `;
exports[`basics a component cannot be mounted in a detached node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`basics a component inside a component 1`] = ` exports[`basics a component inside a component 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -261,6 +274,19 @@ exports[`basics can mount a simple component with props 1`] = `
}" }"
`; `;
exports[`basics cannot mount on a documentFragment 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>content</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`basics child can be updated 1`] = ` exports[`basics child can be updated 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -1002,6 +1028,19 @@ exports[`basics three level of components with collapsing root nodes 3`] = `
}" }"
`; `;
exports[`basics throws if mounting on target=null 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<span>simple vnode</span>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`basics two child components 1`] = ` exports[`basics two child components 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -320,6 +320,50 @@ exports[`can catch errors can catch an error in a component render function 3`]
}" }"
`; `;
exports[`can catch errors can catch an error in onmounted 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(null, false, false, false, []);
return function template(ctx, node, key = \\"\\") {
let b2, b3;
b2 = text(\`Main\`);
if (ctx['state'].ok) {
const Comp1 = ctx['component'];
b3 = toggler(Comp1, comp1({}, (Comp1).name + key + \`__1\`, node, this, Comp1));
}
return multi([b2, b3]);
}
}"
`;
exports[`can catch errors can catch an error in onmounted 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>Error!!!</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`can catch errors can catch an error in onmounted 4`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div>perfect</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`can catch errors can catch an error in the constructor call of a component render function 1`] = ` exports[`can catch errors can catch an error in the constructor call of a component render function 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -683,7 +683,7 @@ exports[`lifecycle hooks sub widget (inside sub node): hooks are correctly calle
}" }"
`; `;
exports[`lifecycle hooks timeout in onWillStart doesn't emit a warning if app is destroyed 1`] = ` exports[`lifecycle hooks timeout in onWillStart doesn't emit a console log if app is destroyed 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom; let { text, createBlock, list, multi, html, toggler, comment } = bdom;
@@ -696,7 +696,7 @@ exports[`lifecycle hooks timeout in onWillStart doesn't emit a warning if app is
}" }"
`; `;
exports[`lifecycle hooks timeout in onWillStart emits a warning 1`] = ` exports[`lifecycle hooks timeout in onWillStart emits a console log 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom; let { text, createBlock, list, multi, html, toggler, comment } = bdom;
@@ -709,7 +709,7 @@ exports[`lifecycle hooks timeout in onWillStart emits a warning 1`] = `
}" }"
`; `;
exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 1`] = ` exports[`lifecycle hooks timeout in onWillUpdateProps emits a console log 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom; let { text, createBlock, list, multi, html, toggler, comment } = bdom;
@@ -723,7 +723,7 @@ exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 1`] = `
}" }"
`; `;
exports[`lifecycle hooks timeout in onWillUpdateProps emits a warning 2`] = ` exports[`lifecycle hooks timeout in onWillUpdateProps emits a console log 2`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom; let { text, createBlock, list, multi, html, toggler, comment } = bdom;
@@ -66,6 +66,29 @@ exports[`.alike suffix in a simple case 2`] = `
}" }"
`; `;
exports[`.translate props are translated 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({message: \`translated message\`}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`.translate props are translated 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].message);
}
}"
`;
exports[`basics accept ES6-like syntax for props (with getters) 1`] = ` exports[`basics accept ES6-like syntax for props (with getters) 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -412,6 +435,29 @@ exports[`can bind function prop with bind suffix 2`] = `
}" }"
`; `;
exports[`can use .translate suffix 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({message: \`some message\`}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`can use .translate suffix 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].message);
}
}"
`;
exports[`do not crash when binding anonymous function prop with bind suffix 1`] = ` exports[`do not crash when binding anonymous function prop with bind suffix 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -924,6 +924,20 @@ exports[`props validation props: list of strings 1`] = `
}" }"
`; `;
exports[`props validation validate props for root component 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let txt1 = ctx['message'];
return block1([txt1]);
}
}"
`;
exports[`props validation validate simple types 1`] = ` exports[`props validation validate simple types 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -1,5 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`slots .translate slot props are translated 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { capture, markRaw } = helpers;
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
return function template(ctx, node, key = \\"\\") {
const ctx1 = capture(ctx);
return comp1({slots: markRaw({'default': {message: \`translated message\`}})}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`slots .translate slot props are translated 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].slots.default.message);
}
}"
`;
exports[`slots can define a default content 1`] = ` exports[`slots can define a default content 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
@@ -201,6 +226,31 @@ exports[`slots can render only empty slot 1`] = `
}" }"
`; `;
exports[`slots can use .translate suffix on slot props 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { capture, markRaw } = helpers;
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
return function template(ctx, node, key = \\"\\") {
const ctx1 = capture(ctx);
return comp1({slots: markRaw({'default': {message: \`some message\`}})}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`slots can use .translate suffix on slot props 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(ctx['props'].slots.default.message);
}
}"
`;
exports[`slots can use component in default-content of t-slot 1`] = ` exports[`slots can use component in default-content of t-slot 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
+78 -2
View File
@@ -157,7 +157,7 @@ describe("basics", () => {
} catch (e) { } catch (e) {
error = e as Error; error = e as Error;
} }
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier 'ctx'
generated code: generated code:
function(app, bdom, helpers) { function(app, bdom, helpers) {
@@ -182,7 +182,7 @@ function(app, bdom, helpers) {
static components = { Child }; static components = { Child };
static template = xml`<Child/>`; static template = xml`<Child/>`;
} }
const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier const expectedErrorMessage = `Failed to compile anonymous template: Unexpected identifier 'ctx'
generated code: generated code:
function(app, bdom, helpers) { function(app, bdom, helpers) {
@@ -564,6 +564,82 @@ describe("can catch errors", () => {
expect(mockConsoleWarn).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("can catch an error in onmounted", async () => {
class ErrorComponent extends Component {
static template = xml`<div>Error!!!</div>`;
setup() {
useLogLifecycle();
onMounted(() => {
throw new Error("error");
});
}
}
class PerfectComponent extends Component {
static template = xml`<div>perfect</div>`;
setup() {
useLogLifecycle();
}
}
class Main extends Component {
static template = xml`Main<t t-if="state.ok" t-component="component"/>`;
component: any;
state: any;
setup() {
this.state = useState({ ok: false });
useLogLifecycle();
this.component = ErrorComponent;
onError(() => {
this.component = PerfectComponent;
this.render();
});
}
}
const app = await mount(Main, fixture);
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Main:setup",
"Main:willStart",
"Main:willRender",
"Main:rendered",
"Main:mounted",
]
`);
expect(fixture.innerHTML).toBe("Main");
(app as any).state.ok = true;
await nextTick();
expect(fixture.innerHTML).toBe("Main<div>Error!!!</div>");
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Main:willRender",
"ErrorComponent:setup",
"ErrorComponent:willStart",
"Main:rendered",
"ErrorComponent:willRender",
"ErrorComponent:rendered",
"Main:willPatch",
"ErrorComponent:mounted",
"Main:willRender",
"PerfectComponent:setup",
"PerfectComponent:willStart",
"Main:rendered",
]
`);
await nextTick();
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"PerfectComponent:willRender",
"PerfectComponent:rendered",
"Main:willPatch",
"ErrorComponent:willUnmount",
"ErrorComponent:willDestroy",
"PerfectComponent:mounted",
"Main:patched",
]
`);
expect(fixture.innerHTML).toBe("Main<div>perfect</div>");
});
test("calling a hook outside setup should crash", async () => { test("calling a hook outside setup should crash", async () => {
class Root extends Component { class Root extends Component {
static template = xml`<t t-esc="state.value"/>`; static template = xml`<t t-esc="state.value"/>`;
+19 -19
View File
@@ -112,10 +112,10 @@ describe("lifecycle hooks", () => {
await mount(Test, fixture); await mount(Test, fixture);
}); });
test("timeout in onWillStart emits a warning", async () => { test("timeout in onWillStart emits a console log", async () => {
const { warn } = console; const { log } = console;
let warnArgs: any[]; let logArgs: any[];
console.warn = jest.fn((...args) => (warnArgs = args)); console.log = jest.fn((...args) => (logArgs = args));
const { setTimeout } = window; const { setTimeout } = window;
let timeoutCbs: any = {}; let timeoutCbs: any = {};
let timeoutId = 0; let timeoutId = 0;
@@ -138,17 +138,17 @@ describe("lifecycle hooks", () => {
} }
await nextMicroTick(); await nextMicroTick();
await nextMicroTick(); await nextMicroTick();
expect(console.warn).toHaveBeenCalledTimes(1); expect(console.log).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds"); expect(logArgs![0]!.message).toBe("onWillStart's promise hasn't resolved after 3 seconds");
} finally { } finally {
console.warn = warn; console.log = log;
window.setTimeout = setTimeout; window.setTimeout = setTimeout;
} }
}); });
test("timeout in onWillStart doesn't emit a warning if app is destroyed", async () => { test("timeout in onWillStart doesn't emit a console log if app is destroyed", async () => {
const { warn } = console; const { log } = console;
console.warn = jest.fn(); console.log = jest.fn();
const { setTimeout } = window; const { setTimeout } = window;
let timeoutCbs: any = {}; let timeoutCbs: any = {};
let timeoutId = 0; let timeoutId = 0;
@@ -172,14 +172,14 @@ describe("lifecycle hooks", () => {
} }
await nextMicroTick(); await nextMicroTick();
await nextMicroTick(); await nextMicroTick();
expect(console.warn).toHaveBeenCalledTimes(0); expect(console.log).toHaveBeenCalledTimes(0);
} finally { } finally {
console.warn = warn; console.log = log;
window.setTimeout = setTimeout; window.setTimeout = setTimeout;
} }
}); });
test("timeout in onWillUpdateProps emits a warning", async () => { test("timeout in onWillUpdateProps emits a console log", async () => {
class Child extends Component { class Child extends Component {
static template = xml``; static template = xml``;
setup() { setup() {
@@ -193,9 +193,9 @@ describe("lifecycle hooks", () => {
} }
const parent = await mount(Parent, fixture, { test: true }); const parent = await mount(Parent, fixture, { test: true });
const { warn } = console; const { log } = console;
let warnArgs: any[]; let logArgs: any[];
console.warn = jest.fn((...args) => (warnArgs = args)); console.log = jest.fn((...args) => (logArgs = args));
const { setTimeout } = window; const { setTimeout } = window;
let timeoutCbs: any = {}; let timeoutCbs: any = {};
let timeoutId = 0; let timeoutId = 0;
@@ -218,12 +218,12 @@ describe("lifecycle hooks", () => {
delete timeoutCbs[id]; delete timeoutCbs[id];
} }
await tick; await tick;
expect(console.warn).toHaveBeenCalledTimes(1); expect(console.log).toHaveBeenCalledTimes(1);
expect(warnArgs![0]!.message).toBe( expect(logArgs![0]!.message).toBe(
"onWillUpdateProps's promise hasn't resolved after 3 seconds" "onWillUpdateProps's promise hasn't resolved after 3 seconds"
); );
} finally { } finally {
console.warn = warn; console.log = log;
window.setTimeout = setTimeout; window.setTimeout = setTimeout;
} }
}); });
+28
View File
@@ -299,6 +299,34 @@ test("bound functions are considered 'alike'", async () => {
expect(fixture.innerHTML).toBe("3child"); expect(fixture.innerHTML).toBe("3child");
}); });
test("can use .translate suffix", async () => {
class Child extends Component {
static template = xml`<t t-esc="props.message"/>`;
}
class Parent extends Component {
static template = xml`<Child message.translate="some message"/>`;
static components = { Child };
}
await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("some message");
});
test(".translate props are translated", async () => {
class Child extends Component {
static template = xml`<t t-esc="props.message"/>`;
}
class Parent extends Component {
static template = xml`<Child message.translate="some message"/>`;
static components = { Child };
}
await mount(Parent, fixture, { translateFn: () => "translated message" });
expect(fixture.innerHTML).toBe("translated message");
});
test("throw if prop uses an unknown suffix", async () => { test("throw if prop uses an unknown suffix", async () => {
class Child extends Component { class Child extends Component {
static template = xml`<t t-esc="props.val"/>`; static template = xml`<t t-esc="props.val"/>`;
+28
View File
@@ -179,6 +179,34 @@ describe("slots", () => {
expect(fixture.innerHTML).toBe("<span>default empty</span>"); expect(fixture.innerHTML).toBe("<span>default empty</span>");
}); });
test("can use .translate suffix on slot props", async () => {
class Child extends Component {
static template = xml`<t t-esc="props.slots.default.message"/>`;
}
class Parent extends Component {
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
static components = { Child };
}
await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("some message");
});
test(".translate slot props are translated", async () => {
class Child extends Component {
static template = xml`<t t-esc="props.slots.default.message"/>`;
}
class Parent extends Component {
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
static components = { Child };
}
await mount(Parent, fixture, { translateFn: () => "translated message" });
expect(fixture.innerHTML).toBe("translated message");
});
test("default slot with slot scope: shorthand syntax", async () => { test("default slot with slot scope: shorthand syntax", async () => {
let child: any; let child: any;
class Child extends Component { class Child extends Component {
+1 -1
View File
@@ -14,7 +14,7 @@
"default_popup": "popup_app/popup.html" "default_popup": "popup_app/popup.html"
}, },
"permissions": ["scripting", "storage"], "permissions": ["scripting", "storage"],
"host_permissions": ["http://*/*", "https://*/*"], "host_permissions": ["http://*/*", "https://*/*", "file://*"],
"content_security_policy": { "content_security_policy": {
"script-src": "self", "script-src": "self",
"object-src": "self" "object-src": "self"
@@ -109,23 +109,34 @@
object(obj) { object(obj) {
const result = []; const result = [];
let length = 0; let length = 0;
for (const [key, value] of Object.entries(obj)) { if (obj instanceof String) {
if (length > 25) { result[0] = `'${obj.toString()}'`;
result.push("..."); } else if (obj instanceof Array) {
break; return `${obj.constructor.name} ${this.array([...obj])}`;
} else if (obj instanceof Number) {
result[0] = obj.toString();
} else {
for (const [key, value] of Object.entries(obj)) {
if (length > 25) {
result.push("...");
break;
}
const element = key + ": " + this.serializeItem(value);
length += element.length;
result.push(element);
}
for (const key of Object.getOwnPropertySymbols(obj)) {
if (length > 25) {
result.push("...");
break;
}
const element = key.toString() + ": " + this.serializeItem(obj[key]);
length += element.length;
result.push(element);
} }
const element = key + ": " + this.serializeItem(value);
length += element.length;
result.push(element);
} }
for (const key of Object.getOwnPropertySymbols(obj)) { if (obj.constructor.name !== "Object") {
if (length > 25) { return obj.constructor.name + " {" + result.join(", ") + "}";
result.push("...");
break;
}
const element = key.toString() + ": " + this.serializeItem(obj[key]);
length += element.length;
result.push(element);
} }
return "{" + result.join(", ") + "}"; return "{" + result.join(", ") + "}";
}, },
@@ -823,7 +834,7 @@
child.contentType = "set"; child.contentType = "set";
child.hasChildren = true; child.hasChildren = true;
break; break;
case obj instanceof Array: case obj.constructor.name === "Array":
child.contentType = "array"; child.contentType = "array";
child.hasChildren = obj.length > 0; child.hasChildren = obj.length > 0;
break; break;
@@ -834,7 +845,9 @@
case obj instanceof Object: case obj instanceof Object:
child.contentType = "object"; child.contentType = "object";
child.hasChildren = child.hasChildren =
Object.keys(obj).length || Object.getOwnPropertySymbols(obj).length; Object.keys(obj).length ||
Object.getOwnPropertySymbols(obj).length ||
obj.constructor.name !== "Object";
break; break;
default: default:
child.contentType = typeof obj; child.contentType = typeof obj;
-1
View File
@@ -140,7 +140,6 @@ async function startRelease() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
log(`Step ${step++}/${STEPS}: Creating the release...`); log(`Step ${step++}/${STEPS}: Creating the release...`);
const relaseResult = await execCommand(`gh release create v${next} dist/*.js dist/*.zip ${draft} -F ${file}`); const relaseResult = await execCommand(`gh release create v${next} dist/*.js dist/*.zip ${draft} -F ${file}`);
if (relaseResult !== 0) { if (relaseResult !== 0) {