Compare commits

..

25 Commits

Author SHA1 Message Date
Géry Debongnie 42aa9d3ae1 [REL] v1.0.0-beta1 2019-12-03 08:52:04 +01:00
Géry Debongnie 85f26a0286 [IMP] tools: update debug script, add tests
closes #525
closes #526
2019-12-03 08:28:46 +01:00
Géry Debongnie ed4ab51c17 [FIX] component: tricky concurrency issue
This is a really interesting problem: there was a situation where a
component would not have an event handler properly bound. The reason was
that we were in a situation where the scheduler task queue was flushed
at an unfortunate timing:

- parent component template is rendered
- subcomponent is prepared:
   - sub component is willStarted
   - it is rendered (so, fiber counter is set to 0)
- scheduler task queue is flushed => we patch the DOM
- the code registered in the __prepare.then(handler) is executed, which
  add the createHook to the vnode (but after the dom is patched)

Usually, the last two steps happen in a different order, but in an
environment with connected components, we sometimes flush the task queue
at various moments, so some code could be executed between the end of
__prepare and the registered handler.

The fix is interesting: we give a callback to the __prepare function
instead of registering a .then handler to the deferred.  This callback
is then guaranteed to be called at exactly the proper timing.

Thanks seb for the hard work of finding the root cause of this issue

closes #520
2019-12-03 08:23:10 +01:00
Lucas Perais (lpe) 556a4644f0 [ADD] misc: component Portal
The component Portal is used to teleport the content
in its slot as a child of an element
somewhere else in the DOM, usually 'body'

It is designed to be transparent and is just here to do the teleportation task

OwlEvents (with method `Component.trigger`) are redirected from
any Component instanciated within the Portal (and therefore teleported
somewhere else) to the place it would have been without teleportation
i.e. they are re-triggered onto the Portal root node

Co-authored-by: Aaron Bohy <aab@odoo.com>

closes #184
closes #466
2019-12-02 21:04:07 +01:00
Géry Debongnie 821bd0b4b8 [IMP] tooling: add debug code
closes #521
2019-12-02 09:03:48 +01:00
Géry Debongnie 06a6d890d7 [DOC] move some component doc in sub pages
closes #354
2019-12-02 08:55:12 +01:00
Géry Debongnie c1269288f5 [REL] v1.0.0alpha5 2019-11-29 16:11:34 +01:00
Géry Debongnie 8c17bb0411 [FIX] component: reorganize fiber update algorithm
Closes #473
Closes #484
Closes #489
Closes #492
Closes #512

Co-authored-by: Aaron Bohy <aab@odoo.com>
2019-11-29 15:13:37 +01:00
Lucas Perais (lpe) 7f6782d009 [FIX] qweb: cascading t-call and t-raw="0"
Have a t-call having a t-raw="0"
that call is made to a template of the same form
i.e. itself as a t-call t-raw="0" structure

Before this commit, there was recursion crash
This was because the caller to which a t-raw="0" referred to
was incorrect. The caller here is the node which makes the t-call.
In particular, the caller was always set to last caller of the context

After this commit, there is no crash and this imbrication
of t-call and t-raw="0" is well rendered.
To sum up, we count the recursive calls to t-raw="0" and fetch
the caller in the context accordingly

closes #510
2019-11-29 13:17:40 +01:00
Aaron Bohy 6e5d6aa226 [FIX] slots: use correct scope and vars at update
Part of #473
2019-11-28 14:42:46 +01:00
Aaron Bohy 9400c0adad [FIX] playground: stop using class fields
as Edge doesn't support them.

Closes #509
2019-11-28 10:21:45 +01:00
Aaron Bohy 85d4393242 [FIX] t-slot: correct parented relationship with nested slots
Closes #506
2019-11-28 10:15:57 +01:00
Aaron Bohy 6c8b401092 [FIX] component: slots: multiple slots with components
Closes #508
2019-11-27 15:47:55 +01:00
Aaron Bohy 12be815342 [REF] qweb: use global nextID
and reset it to 1 before each test s.t. snapshots are deterministic.
2019-11-27 15:47:55 +01:00
Sébastien Theys 65344dbf1f [FIX] store: fix useStore update when mixing state and props changes
This commit also fixes an issue where updates would not be triggered when the
new and old revNumber where the same but concerned a different object.
2019-11-27 15:09:47 +01:00
Sébastien Theys a6f9b26057 [FIX] store: ensure useStore proper clean up on destroy 2019-11-27 15:09:47 +01:00
Géry Debongnie 6c753bb49f [FIX] qweb: scope t-key directive to node and subnodes
not to siblings

closes #503
2019-11-27 13:31:40 +01:00
Géry Debongnie 1f079b883e [IMP] store: prevent changes to store state in useStore return value
closes #500
2019-11-27 08:18:44 +01:00
Géry Debongnie 8fb35ed969 [FIX] owl: add prettier to devDependencies
also, run prettier on codebase

closes #498
2019-11-27 08:06:17 +01:00
Géry Debongnie a3f2d07b40 [DOC] add link to useSubEnv in environment doc 2019-11-26 22:32:25 +01:00
Sébastien Theys c993278c80 [IMP] store: allow useStore to return primitive types
Related to #404
2019-11-26 22:17:05 +01:00
Géry Debongnie 3aa586db43 [IMP] component: allow custom validator functions for props
closes #375
2019-11-22 15:43:22 +01:00
Géry Debongnie 14db513f3b [FIX] component: allow fragments in method mount
It is now possible to mount components in fragments

closes #494
2019-11-22 15:00:37 +01:00
Géry Debongnie 7bb04185c7 [FIX] playground: fix wms sample
It was crashing when we close a window, because there are two event
handlers for the click event on Window.  But when we close a window, we
actually do not want to update the z index.
2019-11-22 13:41:44 +01:00
Géry Debongnie 48744cfa87 [DOC] add hooks explanation, and testing/debug page
closes #372
closes #419
2019-11-22 13:41:44 +01:00
48 changed files with 4019 additions and 1596 deletions
+4 -4
View File
@@ -1,10 +1,10 @@
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1> <h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
_A no nonsense web framework for structured, dynamic and maintainable applications_ _Class based components with hooks, reactive state and concurrent mode_
## Project Overview ## Project Overview
The Odoo Web Library (OWL) is a smallish (~18kb gzipped) UI framework intended to The Odoo Web Library (OWL) is a smallish (~<20kb gzipped) UI framework intended to
be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern
framework, written in Typescript, taking the best ideas from React and Vue in a framework, written in Typescript, taking the best ideas from React and Vue in a
simple and consistent way. Owl's main features are: simple and consistent way. Owl's main features are:
@@ -104,8 +104,8 @@ Submit a PR!
If you want to use a simple `<script>` tag, the last release can be downloaded here: If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl-1.0.0-alpha4.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha3/owl.js) - [owl-1.0.0-beta1.js](https://github.com/odoo/owl/releases/download/v1.0.0-beta1/owl.js)
- [owl-1.0.0-alpha4.min.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha3/owl.min.js) - [owl-1.0.0-beta1.min.js](https://github.com/odoo/owl/releases/download/v1.0.0-beta1/owl.min.js)
Some npm scripts are available: Some npm scripts are available:
+132
View File
@@ -0,0 +1,132 @@
# 🦉 Testing Owl components 🦉
## Content
- [Overview](#overview)
- [Unit Tests](#unit-tests)
## Overview
It is a good practice to test applications and components to ensure that they
behave as expected. There are many ways to test a user interface: manual
testing, integration testing, unit testing, ...
In this section, we will discuss how to write unit tests for components, and
how to debug them if necessary.
## Unit Tests
Writing unit tests for Owl components really depends on the testing framework
used in a project. But usually, it involves the following steps:
- create a test file: for example `SomeComponent.test.js`,
- in that file, import the code for `SomeComponent`,
- add a test case:
- create a real DOM element to use as test fixture,
- create a test environment
- create an instance of `SomeComponent`, mount it to the fixture
- interact with the component and assert some properties.
To help with this, it is useful to have a `helper.js` file that contains some
common utility functions:
```js
export function makeTestFixture() {
let fixture = document.createElement("div");
document.body.appendChild(fixture);
return fixture;
}
export function nextTick() {
let requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
return new Promise(function(resolve) {
setTimeout(() => requestAnimationFrame(() => resolve()));
});
}
export function makeTestEnv() {
// application specific. It needs a way to load actual templates
const templates = ...;
return {
qweb: new QWeb(templates),
..., // each service can be mocked here
};
}
```
With such a file, a typical test suite for Jest will look like this:
```js
// in SomeComponent.test.js
import { SomeComponent } from "../../src/ui/SomeComponent";
import { nextTick, makeTestFixture, makeTestEnv} from '../helpers';
//------------------------------------------------------------------------------
// Setup
//------------------------------------------------------------------------------
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
// we set here the default environment for each component created in the test
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("SomeComponent", () => {
test("component behaves as expected", async () => {
const props = {...}; // depends on the component
const comp = new SomeComponent(null, props);
await comp.mount(fixture);
// do some assertions
expect(...).toBe(...);
fixture.querySelector('button').click();
await nextTick();
// some other assertions
expect(...).toBe(...);
});
});
```
Note that Owl does wait for the next animation frame to actually update the DOM.
This is why it is necessary to wait with the `nextTick` (or other methods) to
make sure that the DOM is up-to-date.
It is sometimes useful to wait until Owl is completely done updating components
(in particular, if we have a highly concurrent user interface). This next
helper simply polls every 20ms the internal Owl task queue and returns a promise
which resolves when it is empty:
```js
function afterUpdates() {
return new Promise((resolve, reject) => {
let timer = setTimeout(poll, 20);
let counter = 0;
function poll() {
counter++;
if (owl.Component.scheduler.tasks.length) {
if (counter > 10) {
reject(new Error("timeout"));
} else {
timer = setTimeout(poll);
}
} else {
resolve();
}
}
});
}
```
+1 -1
View File
@@ -297,7 +297,7 @@ A lot of stuff happened here:
- the `Task` component has a `props` key: this is only useful for validation - the `Task` component has a `props` key: this is only useful for validation
purpose. It says that each `Task` should be given exactly one prop, named purpose. It says that each `Task` should be given exactly one prop, named
`task`. If this is not the case, Owl will throw an `task`. If this is not the case, Owl will throw an
[error](../reference/component.md#props-validation). This is extremely [error](../reference/props_validation.md). This is extremely
useful when refactoring components useful when refactoring components
- finally, to activate the props validation, we need to set Owl's - finally, to activate the props validation, we need to set Owl's
[mode](../reference/config.md#mode) to `dev`. This is done in the `setup` [mode](../reference/config.md#mode) to `dev`. This is done in the `setup`
+17 -14
View File
@@ -4,6 +4,7 @@
- [Animations](reference/animations.md) - [Animations](reference/animations.md)
- [Component](reference/component.md) - [Component](reference/component.md)
- [Concurrency Model](reference/concurrency_model.md)
- [Configuration](reference/config.md) - [Configuration](reference/config.md)
- [Context](reference/context.md) - [Context](reference/context.md)
- [Environment](reference/environment.md) - [Environment](reference/environment.md)
@@ -12,6 +13,7 @@
- [Miscellaneous Components](reference/misc.md) - [Miscellaneous Components](reference/misc.md)
- [Observer](reference/observer.md) - [Observer](reference/observer.md)
- [Props](reference/props.md) - [Props](reference/props.md)
- [Props Validation](reference/props_validation.md)
- [QWeb Templating Language](reference/qweb_templating_language.md) - [QWeb Templating Language](reference/qweb_templating_language.md)
- [QWeb Engine](reference/qweb_engine.md) - [QWeb Engine](reference/qweb_engine.md)
- [Router](reference/router.md) - [Router](reference/router.md)
@@ -23,6 +25,7 @@
- [Quick Start: create an (almost) empty Owl application](learning/quick_start.md) - [Quick Start: create an (almost) empty Owl application](learning/quick_start.md)
- [Tutorial: create a TodoList application](learning/tutorial_todoapp.md) - [Tutorial: create a TodoList application](learning/tutorial_todoapp.md)
- [Testing Owl components](learning/testing_components.md)
## Miscellaneous ## Miscellaneous
@@ -47,20 +50,20 @@ and `EventBus` is exported as `owl.core.EventBus`):
``` ```
Component misc Component misc
Context AsyncRoot Context AsyncRoot
QWeb router QWeb Portal
Store Link Store router
useState RouteComponent useState Link
config Router config RouteComponent
mode tags mode Router
core xml core tags
EventBus utils EventBus xml
Observer debounce Observer utils
hooks escape hooks debounce
onWillStart loadJS onWillStart escape
onMounted loadFile onMounted loadJS
onWillUpdateProps shallowEqual onWillUpdateProps loadFile
onWillPatch whenReady onWillPatch shallowEqual
onPatched onPatched whenReady
onWillUnmount onWillUnmount
useContext useContext
useState useState
+16 -230
View File
@@ -14,12 +14,9 @@
- [Composition](#composition) - [Composition](#composition)
- [Event Handling](#event-handling) - [Event Handling](#event-handling)
- [Form Input Bindings](#form-input-bindings) - [Form Input Bindings](#form-input-bindings)
- [Semantics](#semantics)
- [Props Validation](#props-validation)
- [References](#references) - [References](#references)
- [Slots](#slots) - [Slots](#slots)
- [Dynamic sub components](#dynamic-sub-components) - [Dynamic sub components](#dynamic-sub-components)
- [Asynchronous Rendering](#asynchronous-rendering)
- [Error Handling](#error-handling) - [Error Handling](#error-handling)
- [Functional Components](#functional-components) - [Functional Components](#functional-components)
- [SVG components](#svg-components) - [SVG components](#svg-components)
@@ -204,7 +201,7 @@ to be called in the constructor.
* **`props`** (Object, optional): if given, this is an object that describes the * **`props`** (Object, optional): if given, this is an object that describes the
type and shape of the (actual) props given to the component. If Owl mode is type and shape of the (actual) props given to the component. If Owl mode is
`dev`, this will be used to validate the props each time the component is `dev`, this will be used to validate the props each time the component is
created/updated. See [Props Validation](#props-validation) for more information. created/updated. See [Props Validation](props_validation.md) for more information.
```js ```js
class Counter extends owl.Component { class Counter extends owl.Component {
@@ -239,7 +236,7 @@ We explain here all the public methods of the `Component` class.
- **`mount(target)`** (async): this is the main way a - **`mount(target)`** (async): this is the main way a
component is added to the DOM: the root component is mounted to a target component is added to the DOM: the root component is mounted to a target
HTMLElement. Obviously, this is asynchronous, since each children need to be HTMLElement (or document fragment). Obviously, this is asynchronous, since each children need to be
created as well. Most applications will need to call `mount` exactly once, on created as well. Most applications will need to call `mount` exactly once, on
the root component. the root component.
@@ -247,6 +244,20 @@ We explain here all the public methods of the `Component` class.
automatically re-rendered to ensure that changes in its state (or something automatically re-rendered to ensure that changes in its state (or something
in the environment, or in the store, or ...) will be taken into account. in the environment, or in the store, or ...) will be taken into account.
If a component is mounted inside an element or a fragment which is not in the
DOM, then it will be rendered fully, but not active: the `mounted` hooks will
not be called. This is sometimes useful if we want to load an application in
memory. In that case, we need to mount the root component again in an element
which is in the DOM:
```js
const app = new App();
await app.mount(document.createDocumentFragment());
// app is rendered in memory, but not active
await app.mount(document.body);
// app is now visible
```
- **`unmount()`**: in case a component needs to be detached/removed from the DOM, this - **`unmount()`**: in case a component needs to be detached/removed from the DOM, this
method can be used. Most applications should not call `unmount`, this is more method can be used. Most applications should not call `unmount`, this is more
useful to the underlying component system. useful to the underlying component system.
@@ -731,188 +742,6 @@ update a number whenever the change is done.
Note: the online playground has an example to show how it works. Note: the online playground has an example to show how it works.
### Semantics
We give here an informal description of the way components are created/updated
in an application. Here, ordered lists describe actions that are executed
sequentially, bullet lists describe actions that are executed in parallel.
**Scenario 1: initial rendering** Imagine we want to render the following component tree:
```
A
/ \
B C
/ \
D E
```
Here is what happen whenever we mount the root
component (with some code like `app.mount(document.body)`).
1. `willStart` is called on `A`
2. when it is done, template `A` is rendered.
- component `B` is created
1. `willStart` is called on `B`
2. template `B` is rendered
- component `C` is created
1. `willStart` is called on `C`
2. template `C` is rendered
- component `D` is created
1. `willStart` is called on `D`
2. template `D` is rendered
- component `E` is created
1. `willStart` is called on `E`
2. template `E` is rendered
3. component `A` is patched into a detached DOM element. This will create the actual
component `A` DOM structure. The patching process will cause recursively the
patching of the `B`, `C`, `D` and `E` DOM trees. (so the actual full DOM tree is created
in one pass)
4. the component `A` root element is actually appended to `document.body`
5. The method `mounted` is called recursively on all components in the following
order: `B`, `D`, `E`, `C`, `A`.
**Scenario 2: rerendering a component**. Now, let's assume that the user clicked on some
button in `C`, and this results in a state update, which is supposed to:
- update `D`,
- remove `E`,
- add new component `F`.
So, the component tree should look like this:
```
A
/ \
B C
/ \
D F
```
Here is what Owl will do:
1. because of a state change, the method `render` is called on `C`
2. template `C` is rendered again
- component `D` is updated:
1. hook `willUpdateProps` is called on `D` (async)
2. template `D` is rerendered
- component `F` is created:
1. hook `willStart` is called on `E` (async)
2. template `F` is rendered
3. `willPatch` hooks are called recursively on components `C`, `D` (not on `F`,
because it is not mounted yet)
4. component `C` is patched, which will cause recursively:
2. `willUnmount` hook on `E`, then destruction of `E`,
3. (initial) patching of `F`, then hook `mounted` is called on `F`
5. patching of `D`
6. `patched` hooks are called on `D`, `C`
### Props Validation
As an application becomes complex, it may be quite unsafe to define props in an informal way. This leads to two issues:
- hard to tell how a component should be used, by looking at its code.
- unsafe, it is easy to send wrong props into a component, either by refactoring a component, or one of its parents.
A props type system solves both issues, by describing the types and shapes
of the props. Here is how it works in Owl:
- `props` key is a static key (so, different from `this.props` in a component instance)
- it is optional: it is ok for a component to not define a `props` key.
- props are validated whenever a component is created/updated
- props are only validated in `dev` mode (see [config page](config.md#mode))
- if a key does not match the description, an error is thrown
- it validates keys defined in (static) `props`. Additional keys given by the
parent will cause an error.
For example:
```js
class ComponentA extends owl.Component {
static props = ['id', 'url'];
...
}
class ComponentB extends owl.Component {
static props = {
count: {type: Number},
messages: {
type: Array,
element: {type: Object, shape: {id: Boolean, text: 'string' }
},
date: Date,
combinedVal: [Number, Boolean]
};
...
}
```
- it is an object or a list of strings
- a list of strings is a simplified props definition, which only lists the name
of the props. Also, if the name ends with `?`, it is considered optional.
- all props are by default required, unless they are defined with `optional: true`
(in that case, validation is only done if there is a value)
- valid types are: `Number, String, Boolean, Object, Array, Date, Function`, and all
constructor functions (so, if you have a `Person` class, it can be used as a type)
- arrays are homogeneous (all elements have the same type/shape)
For each key, a `prop` definition is either a boolean, a constructor, a list of constructors, or an object:
- a boolean: indicate that the props exists, and is mandatory.
- a constructor: this should describe the type, for example: `id: Number` describe
the props `id` as a number
- a list of constructors. In that case, this means that we allow more than one
type. For example, `id: [Number, String]` means that `id` can be either a string
or a number.
- an object. This makes it possible to have more expressive definition. The following sub keys are then allowed:
- `type`: the main type of the prop being validated
- `element`: if the type was `Array`, then the `element` key describes the type of each element in the array. It is optional (not set means that we only validate the array, not its elements),
- `shape`: if the type was `Object`, then the `shape` key describes the interface of the object. It is optional (not set means that we only validate the object, not its elements)
Examples:
```js
// only the existence of those 3 keys is documented
static props = ['message', 'id', 'date'];
```
```js
// size is optional
static props = ['message', 'size?'];
```
```js
static props = {
messageIds: {type: Array, element: Number}, // list of number
otherArr: {type: Array}, // just array. no validation is made on sub elements
otherArr2: Array, // same as otherArr
someObj: {type: Object}, // just an object, no internal validation
someObj2: {
type: Object,
shape: {
id: Number,
name: {type: String, optional: true},
url: String
]}, // object, with keys id (number), name (string, optional) and url (string)
someFlag: Boolean, // a boolean, mandatory (even if `false`)
someVal: [Boolean, Date], // either a boolean or a date
otherValue: true, // indicates that it is a prop
};
```
### References ### References
The `useRef` hook is useful when we need a way to interact with some inside part The `useRef` hook is useful when we need a way to interact with some inside part
@@ -1070,49 +899,6 @@ component class.
Note that the `t-component` directive can only be used on `<t>` nodes. Note that the `t-component` directive can only be used on `<t>` nodes.
### Asynchronous Rendering
Working with asynchronous code always adds a lot of complexity to a system. Whenever
different parts of a system are active at the same time, one needs to think
carefully about all possible interactions. Clearly, this is also true for Owl
components.
There are two different common problems with Owl asynchronous rendering model:
- any component can delay the rendering (initial and subsequent) of the whole
application
- for a given component, there are two independant situations that will trigger an
asynchronous rerendering: a change in the state, or a change in the props.
These changes may be done at different times, and Owl has no way of knowing
how to reconcile the resulting renderings.
Here are a few tips on how to work with asynchronous components:
1. Minimize the use of asynchronous components!
2. Maybe move the asynchronous logic in a store, which then triggers (mostly)
synchronous renderings
3. Lazy loading external libraries is a good use case for async rendering. This
is mostly fine, because we can assume that it will only takes a fraction of a
second, and only once (see [`owl.utils.loadJS`](utils.md#loadjs))
4. For all the other cases, the [`AsyncRoot`](misc.md#asyncroot) component is there to help you. When
this component is met, a new rendering
sub tree is created, such that the rendering of that component (and its
children) is not tied to the rendering of the rest of the interface. It can
be used on an asynchronous component, to prevent it from delaying the
rendering of the whole interface, or on a synchronous one, such that its
rendering isn't delayed by other (asynchronous) components. Note that this
directive has no effect on the first rendering, but only on subsequent ones
(triggered by state or props changes).
```xml
<div t-name="ParentComponent">
<SyncChild />
<AsyncRoot>
<AsyncChild/>
</AsyncRoot>
</div>
```
### Error Handling ### Error Handling
By default, whenever an error occurs in the rendering of an Owl application, we By default, whenever an error occurs in the rendering of an Owl application, we
+183
View File
@@ -0,0 +1,183 @@
# 🦉 Concurrency Model 🦉
## Content
- [Overview](#overview)
- [Rendering Components](#rendering-components)
- [Semantics](#semantics)
- [Asynchronous Rendering](#asynchronous-rendering)
## Overview
Owl was designed from the very beginning with asynchronous components. This comes
from the `willStart` and the `willUpdateProps` lifecycle hooks. With these
methods, it is possible to build complex highly concurrent applications.
Owl concurrent mode has several benefits: it makes it possible to delay the
rendering until some asynchronous operation is complete, it makes it possible
to lazy load libraries, while keeping the previous screen completely functional.
It is also good for performance reasons: Owl uses it to only apply the result of
many different renderings only once in an animation frame. Owl can cancel
a rendering that is no longer relevant, restart it, reuse it in some cases.
But even though using concurrency is quite simple (and is the default behaviour),
asynchrony is difficult, because it introduces an additional dimension that
vastly increase the complexity of an application. This section will explain
how Owl manages this complexity, how concuurent rendering works in a general way.
## Rendering Components
The word _rendering_ is a little vague, so, let us explain more precisely the
process by which Owl components are displayed on a screen.
When a component is mounted or updated, a new rendering is started. It has
two phases: _virtual rendering_ and _patching_.
### Virtual rendering
This phase represent the process of rendering a template, in memory, which create a virtual representation of the desired component html). The output of this phase is a
virtual DOM.
It is asynchronous: each subcomponents needs to either be created (so, `willStart`
will need to be called), or updated (which is done with the `willUpdateProps`
method). This is completely a recursive process: a component is the root of a
component tree, and each sub component needs to be (virtually) rendered.
### Patching
Once a rendering is complete, it will be applied on the next animation frame.
This is done synchronously: the whole component tree is patched to the real
DOM.
## Semantics
We give here an informal description of the way components are created/updated
in an application. Here, ordered lists describe actions that are executed
sequentially, bullet lists describe actions that are executed in parallel.
**Scenario 1: initial rendering** Imagine we want to render the following component tree:
```
A
/ \
B C
/ \
D E
```
Here is what happen whenever we mount the root
component (with some code like `app.mount(document.body)`).
1. `willStart` is called on `A`
2. when it is done, template `A` is rendered.
- component `B` is created
1. `willStart` is called on `B`
2. template `B` is rendered
- component `C` is created
1. `willStart` is called on `C`
2. template `C` is rendered
- component `D` is created
1. `willStart` is called on `D`
2. template `D` is rendered
- component `E` is created
1. `willStart` is called on `E`
2. template `E` is rendered
3. each components are patched into a detached DOM element, in the following order:
`E`, `D`, `C`, `B`, `A`. (so the actual full DOM tree is created
in one pass)
4. the component `A` root element is actually appended to `document.body`
5. The method `mounted` is called recursively on all components in the following
order: `E`, `D`, `C`, `B`, `A`.
**Scenario 2: rerendering a component**. Now, let's assume that the user clicked on some
button in `C`, and this results in a state update, which is supposed to:
- update `D`,
- remove `E`,
- add new component `F`.
So, the component tree should look like this:
```
A
/ \
B C
/ \
D F
```
Here is what Owl will do:
1. because of a state change, the method `render` is called on `C`
2. template `C` is rendered again
- component `D` is updated:
1. hook `willUpdateProps` is called on `D` (async)
2. template `D` is rerendered
- component `F` is created:
1. hook `willStart` is called on `E` (async)
2. template `F` is rendered
3. `willPatch` hooks are called recursively on components `C`, `D` (not on `F`,
because it is not mounted yet)
4. components `F`, `D` are patched in that order
5. component `C` is patched, which will cause recursively:
1. `willUnmount` hook on `E`
2. destruction of `E`,
6. `mounted` hook is called on `F`, `patched` hooks are called on `D`, `C`
Tags are very small helpers to make it easy to write inline templates. There is
only one currently available tag: `xml`, but we plan to add other tags later,
such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
### Asynchronous Rendering
Working with asynchronous code always adds a lot of complexity to a system. Whenever
different parts of a system are active at the same time, one needs to think
carefully about all possible interactions. Clearly, this is also true for Owl
components.
There are two different common problems with Owl asynchronous rendering model:
- any component can delay the rendering (initial and subsequent) of the whole
application
- for a given component, there are two independant situations that will trigger an
asynchronous rerendering: a change in the state, or a change in the props.
These changes may be done at different times, and Owl has no way of knowing
how to reconcile the resulting renderings.
Here are a few tips on how to work with asynchronous components:
1. Minimize the use of asynchronous components!
2. Maybe move the asynchronous logic in a store, which then triggers (mostly)
synchronous renderings
3. Lazy loading external libraries is a good use case for async rendering. This
is mostly fine, because we can assume that it will only takes a fraction of a
second, and only once (see [`owl.utils.loadJS`](utils.md#loadjs))
4. For all the other cases, the [`AsyncRoot`](misc.md#asyncroot) component is there to help you. When
this component is met, a new rendering
sub tree is created, such that the rendering of that component (and its
children) is not tied to the rendering of the rest of the interface. It can
be used on an asynchronous component, to prevent it from delaying the
rendering of the whole interface, or on a synchronous one, such that its
rendering isn't delayed by other (asynchronous) components. Note that this
directive has no effect on the first rendering, but only on subsequent ones
(triggered by state or props changes).
```xml
<div t-name="ParentComponent">
<SyncChild />
<AsyncRoot>
<AsyncChild/>
</AsyncRoot>
</div>
```
+22
View File
@@ -4,6 +4,7 @@
- [Overview](#overview) - [Overview](#overview)
- [Setting an Environment](#setting-an-environment) - [Setting an Environment](#setting-an-environment)
- [Using a sub environment](#using-a-sub-environment)
- [Content of an Environment](#content-of-an-environment) - [Content of an Environment](#content-of-an-environment)
## Overview ## Overview
@@ -65,6 +66,27 @@ by simply doing this:
Component.env = myEnv; // will be the default env for all components Component.env = myEnv; // will be the default env for all components
``` ```
Note that this environment is the global owl environment for an application. The
next section explains how to extend an environment for a specific sub component
and its children.
## Using a sub environment
It is sometimes useful to add one (or more) specific keys to the environment,
from the perspective of a specific component and its children. In that case, the
solution presented above will not work, since it sets the global environment.
There is a hook for this situation: [`useSubEnv`](hooks.md#usesubenv).
```js
class FormComponent extends Component {
constructor(parent, props) {
super(parent, props);
useSubEnv({ myKey: someValue });
}
}
```
## Content of an Environment ## Content of an Environment
Some good use cases for additional keys in the environment are: Some good use cases for additional keys in the environment are:
+9 -3
View File
@@ -152,9 +152,15 @@ class SomeComponent extends Component {
} }
``` ```
In a hook, the `Component.current` static property is the reference to the As you can see, the `useState` hook does not need to be given a reference to
component instance that is currently being created. Hooks need to be called in the component. This is possible because there is a way to get a reference to the
the constructor to ensure that this reference is properly set. current component: the `Component.current` static property is the reference to the
component instance that is currently being created.
Hooks need to be called in the constructor to ensure that this reference is
properly set. This is also a good thing for performance reasons (Owl can use
this to optimize its implementation), and for a clean architecture (this makes
it easier for developers to understand what is really happening in a component).
### `useState` ### `useState`
+111
View File
@@ -1,5 +1,116 @@
# 🦉 Miscellaneous 🦉 # 🦉 Miscellaneous 🦉
## Content
- [Portal](#portal)
- [AsyncRoot](#asyncroot)
## `Portal`
### Overview
The component `Portal` is meant to be used as a transparent way to 'teleport' a piece
of DOM to the node represented by its sole `target` props.
This component aims at helping the implementation of the needed infrastructure
for modals (as in `bootstrap-modal`).
### Usage
The content it will teleport is defined within the `<Portal>` node and
internally uses the `default` [Slot](component.md#slots).
This slot must contain only **one** node, which in turn can have as many children as necessary.
The element under which the content will be teleported is represented as a selector
by the `target` props which only accepts a string as value.
The `target` props only supports static selector, and is not meant to be passed to `Portal`
as a variable. Namely, `<Portal target="'body'" />` is the intended use.
By contrast, `<Portal target="state.target" />` is not supported.
The component `Portal` has no particular state, rather it is meant to be a slave to its parent,
and ultimately just a way for the parent to teleport a piece of its own DOM elsewhere.
The `Portal`'s root node is always `<portal/>` and is placed where the teleported content
_would have_ been. It is this element that the [teleported events](#expected-behaviors) are re-directed on.
### Example
The canonic use-case is to implement a Dialog, where a Component may choose to break the natural
workflow to help the user put in some data, which it could use later on.
JavaScript:
```js
const { Component } = owl;
const { Portal } = owl.misc;
class TeleportedComponent extends Component {}
class App extends Component {
static components = { Portal, TeleportedComponent };
}
const app = new App();
app.mount(document.body);
```
XML:
```xml
<templates>
<div t-name="TeleportedComponent">
<span>I will move soon enough</span>
</div>
<div t-name="App">
<span>I am like the rest of us</span>
<Portal target="'body'">
<TeleportedComponent />
</Portal>
</div>
</templates>
```
In this example, the `Portal` component will teleport the `TeleportedComponent`'s `div` as a child of the `body`.
`TeleportedComponent` is acting as a Dialog here.
The resulting DOM will look like:
```xml
<body>
<div>
<span>I am like the rest of us</span>
<portal></portal>
</div>
<div>
<span>I will move soon enough</span>
</div>
</body>
```
### Expected Behaviors
The teleported piece is updated as any other `Component`'s DOM and in the same sequence.
Namely the teleported piece will be updated in function of its parents components, and patched as
a normal child.
The [_business_ events](component.md#event-handling) triggered by a child component will be stopped
to not bubble outside of the `target`. They will, on the other hand, be re-directed onto the
`Portal`'s root node and bubble up the DOM as if it were triggered by a regular child component.
Beware that those re-directed events are copies of the original event.
They have:
- The same payload.
- The same `originalComponent` than their original counterpart,
that is the actual Component that triggered it.
- A **different** `target` property than their original counterpart.
The `target` of a re-directed event is necessarily the `Portal`'s root node.
Pure DOM events do not follow this pattern and are free to bubble their natural, unaltered way
up to the `body`.
## `AsyncRoot` ## `AsyncRoot`
When this component is used, a new rendering sub tree is created, such that the When this component is used, a new rendering sub tree is created, such that the
+103
View File
@@ -0,0 +1,103 @@
# 🦉 Props Validation 🦉
As an application becomes complex, it may be quite unsafe to define props in an informal way. This leads to two issues:
- hard to tell how a component should be used, by looking at its code.
- unsafe, it is easy to send wrong props into a component, either by refactoring a component, or one of its parents.
A props type system solves both issues, by describing the types and shapes
of the props. Here is how it works in Owl:
- `props` key is a static key (so, different from `this.props` in a component instance)
- it is optional: it is ok for a component to not define a `props` key.
- props are validated whenever a component is created/updated
- props are only validated in `dev` mode (see [config page](config.md#mode))
- if a key does not match the description, an error is thrown
- it validates keys defined in (static) `props`. Additional keys given by the
parent will cause an error.
For example:
```js
class ComponentA extends owl.Component {
static props = ['id', 'url'];
...
}
class ComponentB extends owl.Component {
static props = {
count: {type: Number},
messages: {
type: Array,
element: {type: Object, shape: {id: Boolean, text: 'string' }
},
date: Date,
combinedVal: [Number, Boolean]
};
...
}
```
- it is an object or a list of strings
- a list of strings is a simplified props definition, which only lists the name
of the props. Also, if the name ends with `?`, it is considered optional.
- all props are by default required, unless they are defined with `optional: true`
(in that case, validation is only done if there is a value)
- valid types are: `Number, String, Boolean, Object, Array, Date, Function`, and all
constructor functions (so, if you have a `Person` class, it can be used as a type)
- arrays are homogeneous (all elements have the same type/shape)
For each key, a `prop` definition is either a boolean, a constructor, a list of constructors, or an object:
- a boolean: indicate that the props exists, and is mandatory.
- a constructor: this should describe the type, for example: `id: Number` describe
the props `id` as a number
- a list of constructors. In that case, this means that we allow more than one
type. For example, `id: [Number, String]` means that `id` can be either a string
or a number.
- an object. This makes it possible to have more expressive definition. The following sub keys are then allowed (but not mandatory):
- `type`: the main type of the prop being validated
- `element`: if the type was `Array`, then the `element` key describes the type of each element in the array. If it is not set, then we only validate the array, not its elements,
- `shape`: if the type was `Object`, then the `shape` key describes the interface of the object. If it is not set, then we only validate the object, not its elements,
- `validate`: this is a function which should return a boolean to determine if
the value is valid or not. Useful for custom validation logic.
Examples:
```js
// only the existence of those 3 keys is documented
static props = ['message', 'id', 'date'];
```
```js
// size is optional
static props = ['message', 'size?'];
```
```js
static props = {
messageIds: {type: Array, element: Number}, // list of number
otherArr: {type: Array}, // just array. no validation is made on sub elements
otherArr2: Array, // same as otherArr
someObj: {type: Object}, // just an object, no internal validation
someObj2: {
type: Object,
shape: {
id: Number,
name: {type: String, optional: true},
url: String
]}, // object, with keys id (number), name (string, optional) and url (string)
someFlag: Boolean, // a boolean, mandatory (even if `false`)
someVal: [Boolean, Date], // either a boolean or a date
otherValue: true, // indicates that it is a prop
kindofsmallnumber: {
type: Number,
validate: n => (0 <= n && n <= 10)
},
size: {
validate: e => ["small", "medium", "large"].includes(e)
},
};
```
+21 -10
View File
@@ -252,19 +252,30 @@ The `useStore` hook is used to select some part of the store state. It accepts
two arguments: two arguments:
- a selector function, which takes the store state as first argument (and the - a selector function, which takes the store state as first argument (and the
component props as second argument) and returns component props as second argument) and which must return the part of the
an object or an array (which will be then observed), store state that will be made available and observed for changes,
- optionally, an object with a `store` key (if we want to override the default - optionally, an object which can have the following optional keys:
store) and an equality function (if we want to specialize the comparison). - a `store` key containing a store object if we want to use another store than
the default store,
- an `isEqual` key containing an equality function if we want to specialize
the comparison (the function must accept two arguments: the previous result
and the new result, and must return whether they are equal),
- and an `onUpdate` key containing an update function if we want to execute an
arbitrary code every time the selected state changes (the function will
receive one argument, the new result, and can execute arbitrary code).
If the `useStore` callback selects a sub part of the store state, the component If the `useStore` selector returns a sub part of the store state, the component
will only be rerendered whenever this part of the state changes. Otherwise, it will only be rerendered whenever this part of the state changes. Otherwise, it
will perform a strict equality check and will update the component every time this will perform a strict equality check (unless the `isEqual` option is defined,
check fails. then it will call it) and will update the component every time this check fails.
Also, it may not be obvious, but it is crucial to remember that the selector Note that if the selector function returns a primitive type, the result of
function should return an object or an array. The reason is that it needs to be `useStore` will be immutable and it will not react to changes. In this case, it
observed, otherwise the component would not be able to react to changes. is important to define the `onUpdate` option to properly update the value
manually when it changes.
Also, the return value from `useStore` is not supposed to be modified. The store
state should only be updated with actions.
### `useDispatch` ### `useDispatch`
+26
View File
@@ -6,6 +6,7 @@
- [Playground](#playground) - [Playground](#playground)
- [Benchmarks](#benchmarks) - [Benchmarks](#benchmarks)
- [Single File Component](#single-file-component) - [Single File Component](#single-file-component)
- [Debugging Script](#debugging-script)
## Overview ## Overview
@@ -78,3 +79,28 @@ Note that the above example has an inline xml comment, just after the `xml` call
This is useful for some editor plugins, such as the VS Code addon This is useful for some editor plugins, such as the VS Code addon
`Comment tagged template`, which, if installed, add syntax highlighting to the `Comment tagged template`, which, if installed, add syntax highlighting to the
content of the template string. content of the template string.
## Debugging Script
## Debugging
Non trivial applications become quickly more difficult to understand. It is then
useful to have a solid understanding of what is going on. To help with that,
logging useful information is extremely valuable. There is a [javascript file](../tools/debug.js) which can be evaluated in an application.
Once it is executed, it will log a lot of information on each component main hooks. The following code is a minified version to make it easier to copy/paste:
```
function debugOwl(o,t){let e,n="[OWL_DEBUG]";function l(o){let t=JSON.stringify(o||{});return t.length>200&&(t=t.slice(0,200)+"..."),t}if(Object.defineProperty(o.Component,"current",{get:()=>e,set(s){e=s;const c=s.constructor.name;if(t.componentBlackList&&t.componentBlackList.test(c))return;if(t.componentWhiteList&&!t.componentWhiteList.test(c))return;let r;Object.defineProperty(e,"__owl__",{get:()=>r,set(e){!function(e,s,c){let r=`${s}<id=${c}>`,i=o=>(!t.methodBlackList||!t.methodBlackList.includes(o))&&!(t.methodWhiteList&&!t.methodWhiteList.includes(o));i("constructor")&&console.log(`${n} ${r} constructor, props=${l(e.props)}`);i("willStart")&&o.hooks.onWillStart(()=>{console.log(`${n} ${r} willStart`)});i("mounted")&&o.hooks.onMounted(()=>{console.log(`${n} ${r} mounted`)});i("willUpdateProps")&&o.hooks.onWillUpdateProps(o=>{console.log(`${n} ${r} willUpdateProps, nextprops=${l(o)}`)});i("willPatch")&&o.hooks.onWillPatch(()=>{console.log(`${n} ${r} willPatch`)});i("patched")&&o.hooks.onPatched(()=>{console.log(`${n} ${r} patched`)});i("willUnmount")&&o.hooks.onWillUnmount(()=>{console.log(`${n} ${r} willUnmount`)});const u=e.__render.bind(e);e.__render=function(...o){console.log(`${n} ${r} rendering template`),u(...o)};const d=e.render.bind(e);e.render=function(...o){return console.log(`${n} ${r} render`),d(...o)};const p=e.mount.bind(e);e.mount=function(...o){return console.log(`${n} ${r} mount`),p(...o)}}(s,c,(r=e).id)}})}}),t.logScheduler){let t=o.Component.scheduler.start,e=o.Component.scheduler.stop;o.Component.scheduler.start=function(){console.log(`${n} scheduler: start running tasks queue`),t.call(this)},o.Component.scheduler.stop=function(){console.log(`${n} scheduler: stop running tasks queue`),e.call(this)}}if(t.logStore){let t=o.Store.prototype.dispatch;o.Store.prototype.dispatch=function(o,...e){return console.log(`${n} store: action '${o}' dispatched. Payload: '${l(e)}'`),t.call(this,o,...e)}}}
debugOwl({
// componentBlackList: /App/, // regexp
// componentWhiteList: /SomeComponent/, // regexp
// methodBlackList: ["mounted"], // list of method names
// methodWhiteList: ["willStart"], // list of method names
logScheduler: false, // display/mute scheduler logs
logStore: true, // display/mute store logs
});
```
Note that it is certainly useful to run this code at some point in an application,
just to get a feel of what each user action implies, for the framework.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "owl-framework", "name": "owl-framework",
"version": "1.0.0-alpha4", "version": "1.0.0-beta1",
"description": "Odoo Web Library (OWL)", "description": "Odoo Web Library (OWL)",
"main": "src/index.ts", "main": "src/index.ts",
"engines": { "engines": {
@@ -39,6 +39,7 @@
"jest-environment-jsdom": "^24.7.1", "jest-environment-jsdom": "^24.7.1",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^1.19.1",
"rollup": "^1.6.0", "rollup": "^1.6.0",
"rollup-plugin-typescript2": "^0.20.1", "rollup-plugin-typescript2": "^0.20.1",
"sass": "^1.16.1", "sass": "^1.16.1",
+5 -12
View File
@@ -1,25 +1,17 @@
# 🦉 OWL Roadmap 🦉 # 🦉 OWL Roadmap 🦉
- Current version: 1.0.0-alpha4 - Current version: 1.0.0-beta1
- Status: mostly stable - Status: mostly stable
This roadmap is only an attempt at predicting Owl's future. Everything may This roadmap is only an attempt at predicting Owl's future. Everything may
change! change!
### November 2019
Owl will be used in various Odoo projects. We plan to:
- fix any issues encountered
- maybe cleanup slightly the router API
- improve the documentation
- improve error handling, add more helpful error messages
### December 2019 ### December 2019
If all goes well, Owl will be upgraded to beta status. From then, no API change, If all goes well, Owl is upgraded to beta status. From now on, no API change,
even small, is expected. even small, is expected (but it still could happen).
### End of 2019 ### End of 2019
@@ -41,7 +33,8 @@ Release v1.0
Maybe: Maybe:
- reimplement vdom to use *block* system, like Vue 3, - reimplement vdom to use *block* system, like Vue 3, which should make Owl
much faster
- refactor `QWeb` to use an intermediate representation (some kind of AST) to - refactor `QWeb` to use an intermediate representation (some kind of AST) to
allow additional optimisations. allow additional optimisations.
+58 -57
View File
@@ -65,6 +65,12 @@ interface Internal<T extends Env, Props> {
// its children, those that are not used anymore and thus can be destroyed // its children, those that are not used anymore and thus can be destroyed
parentLastFiberId: number; parentLastFiberId: number;
// when a rendering is initiated by a parent, it may set variables in 'scope'
// and 'vars' (typically when the component is rendered in a slot). We need to
// store that information in case the component would be re-rendered later on.
scope: any;
vars: any;
boundHandlers: { [key: number]: any }; boundHandlers: { [key: number]: any };
observer: Observer | null; observer: Observer | null;
renderFn: CompiledTemplate; renderFn: CompiledTemplate;
@@ -78,6 +84,8 @@ interface Internal<T extends Env, Props> {
refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null; refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
} }
export const portalSymbol = Symbol("portal"); // FIXME
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// Component // Component
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -184,7 +192,9 @@ export class Component<T extends Env, Props extends {}> {
observer: null, observer: null,
renderFn: qweb.render.bind(qweb, template), renderFn: qweb.render.bind(qweb, template),
classObj: null, classObj: null,
refs: null refs: null,
scope: null,
vars: null
}; };
} }
@@ -281,19 +291,20 @@ export class Component<T extends Env, Props extends {}> {
* *
* Note that a component can be mounted an unmounted several times * Note that a component can be mounted an unmounted several times
*/ */
async mount(target: HTMLElement): Promise<void> { async mount(target: HTMLElement | DocumentFragment): Promise<void> {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
if (__owl__.isMounted) { if (__owl__.isMounted) {
return Promise.resolve(); return Promise.resolve();
} }
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) {
let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`; let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`;
message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`; message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
throw new Error(message); throw new Error(message);
} }
const fiber = new Fiber(null, this, undefined, undefined, false, target); const fiber = new Fiber(null, this, false, target);
fiber.shouldPatch = false;
if (!__owl__.vnode) { if (!__owl__.vnode) {
this.__prepareAndRender(fiber); this.__prepareAndRender(fiber, () => {});
} else { } else {
this.__render(fiber); this.__render(fiber);
} }
@@ -335,7 +346,7 @@ export class Component<T extends Env, Props extends {}> {
// currentFiber that is already rendered (isRendered is true), so we are // currentFiber that is already rendered (isRendered is true), so we are
// about to be mounted // about to be mounted
const isMounted = __owl__.isMounted; const isMounted = __owl__.isMounted;
const fiber = new Fiber(null, this, undefined, undefined, force, null); const fiber = new Fiber(null, this, force, null);
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (__owl__.isMounted || !isMounted) { if (__owl__.isMounted || !isMounted) {
// we are mounted (__owl__.isMounted), or if we are currently being // we are mounted (__owl__.isMounted), or if we are currently being
@@ -388,14 +399,7 @@ export class Component<T extends Env, Props extends {}> {
* willUnmount(). * willUnmount().
*/ */
trigger(eventType: string, payload?: any) { trigger(eventType: string, payload?: any) {
if (this.el) { this.__trigger(this, eventType, payload);
const ev = new OwlEvent(this, eventType, {
bubbles: true,
cancelable: true,
detail: payload
});
this.el.dispatchEvent(ev);
}
} }
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -441,13 +445,7 @@ export class Component<T extends Env, Props extends {}> {
__callMounted() { __callMounted() {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
const children = __owl__.children;
for (let id in children) {
const comp = children[id];
if (!comp.__owl__.isMounted && this.el!.contains(comp.el)) {
comp.__callMounted();
}
}
__owl__.isMounted = true; __owl__.isMounted = true;
__owl__.currentFiber = null; __owl__.currentFiber = null;
this.mounted(); this.mounted();
@@ -463,6 +461,10 @@ export class Component<T extends Env, Props extends {}> {
} }
this.willUnmount(); this.willUnmount();
__owl__.isMounted = false; __owl__.isMounted = false;
if (this.__owl__.currentFiber) {
this.__owl__.currentFiber.isCompleted = true;
this.__owl__.currentFiber.root.counter = 0;
}
const children = __owl__.children; const children = __owl__.children;
for (let id in children) { for (let id in children) {
const comp = children[id]; const comp = children[id];
@@ -471,27 +473,41 @@ export class Component<T extends Env, Props extends {}> {
} }
} }
} }
/**
* Private trigger method, allows to choose the component which triggered
* the event in the first place
*/
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
if (this.el) {
const ev = new OwlEvent(component, eventType, {
bubbles: true,
cancelable: true,
detail: payload
});
const triggerHook = this.env[portalSymbol as any];
if (triggerHook) {
triggerHook(ev);
}
this.el.dispatchEvent(ev);
}
}
/** /**
* The __updateProps method is called by the t-component directive whenever * The __updateProps method is called by the t-component directive whenever
* it updates a component (so, when the parent template is rerendered). * it updates a component (so, when the parent template is rerendered).
*/ */
async __updateProps( async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any, vars: any): Promise<void> {
nextProps: Props, this.__owl__.scope = scope;
parentFiber: Fiber, this.__owl__.vars = vars;
scope: any,
vars: any,
previousSibling?: Fiber | null
): Promise<void> {
const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps); const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
if (shouldUpdate) { if (shouldUpdate) {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force, null); const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
if (!parentFiber.child) { if (!parentFiber.child) {
parentFiber.child = fiber; parentFiber.child = fiber;
} else { } else {
previousSibling!.sibling = fiber; parentFiber.lastChild!.sibling = fiber;
} }
parentFiber.lastChild = fiber;
const defaultProps = (<any>this.constructor).defaultProps; const defaultProps = (<any>this.constructor).defaultProps;
if (defaultProps) { if (defaultProps) {
@@ -517,7 +533,7 @@ export class Component<T extends Env, Props extends {}> {
* Main patching method. We call the virtual dom patch method here to convert * Main patching method. We call the virtual dom patch method here to convert
* a virtual dom vnode into some actual dom. * a virtual dom vnode into some actual dom.
*/ */
__patch(vnode) { __patch(vnode: VNode) {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
const target = __owl__.vnode || document.createElement(vnode.sel!); const target = __owl__.vnode || document.createElement(vnode.sel!);
__owl__.vnode = patch(target, vnode); __owl__.vnode = patch(target, vnode);
@@ -528,15 +544,19 @@ export class Component<T extends Env, Props extends {}> {
* subcomponent is created. It gets its scope and vars, if any, from the * subcomponent is created. It gets its scope and vars, if any, from the
* parent template. * parent template.
*/ */
__prepare(parentFiber: Fiber, scope: any, vars: any, previousSibling?: Fiber | null) { __prepare(parentFiber: Fiber, scope: any, vars: any, cb: CallableFunction): Fiber {
const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force, null); this.__owl__.scope = scope;
this.__owl__.vars = vars;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
fiber.shouldPatch = false; fiber.shouldPatch = false;
if (!parentFiber.child) { if (!parentFiber.child) {
parentFiber.child = fiber; parentFiber.child = fiber;
} else { } else {
previousSibling!.sibling = fiber; parentFiber.lastChild!.sibling = fiber;
} }
return this.__prepareAndRender(fiber); parentFiber.lastChild = fiber;
this.__prepareAndRender(fiber, cb);
return fiber;
} }
__getTemplate(qweb: QWeb): string { __getTemplate(qweb: QWeb): string {
@@ -558,7 +578,7 @@ export class Component<T extends Env, Props extends {}> {
} }
return p._template; return p._template;
} }
async __prepareAndRender(fiber: Fiber) { async __prepareAndRender(fiber: Fiber, cb: CallableFunction) {
try { try {
await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]); await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]);
} catch (e) { } catch (e) {
@@ -570,6 +590,7 @@ export class Component<T extends Env, Props extends {}> {
} }
if (!fiber.isCompleted) { if (!fiber.isCompleted) {
this.__render(fiber); this.__render(fiber);
cb();
} }
} }
@@ -619,26 +640,6 @@ export class Component<T extends Env, Props extends {}> {
} }
} }
/**
* Only called by qweb t-component directive
*/
__mount(fiber: Fiber, elm: HTMLElement): VNode {
if (fiber !== this.__owl__.currentFiber) {
fiber = this.__owl__.currentFiber!; // TODO: check if we can remove fiber arg
}
const vnode = fiber.vnode!;
const __owl__ = this.__owl__;
if (__owl__.classObj) {
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, __owl__.classObj);
}
__owl__.vnode = patch(elm, vnode);
__owl__.currentFiber = null;
if (__owl__.parent!.__owl__.isMounted && !__owl__.isMounted) {
this.__callMounted();
}
return __owl__.vnode;
}
/** /**
* Only called by qweb t-component directive (when t-keepalive is set) * Only called by qweb t-component directive (when t-keepalive is set)
*/ */
+12 -12
View File
@@ -226,7 +226,6 @@ QWeb.addDirective({
let propStr = Object.keys(props) let propStr = Object.keys(props)
.map(k => k + ":" + props[k]) .map(k => k + ":" + props[k])
.join(","); .join(",");
let defID = ctx.generateID();
let componentID = ctx.generateID(); let componentID = ctx.generateID();
const templateKey = ctx.generateTemplateKey(); const templateKey = ctx.generateTemplateKey();
@@ -239,10 +238,6 @@ QWeb.addDirective({
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`); ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`; refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`;
} }
let transitionsInsertCode = "";
if (transition) {
transitionsInsertCode = `utils.transitionInsert(vn, '${transition}');`;
}
let finalizeComponentCode = `w${componentID}.destroy();`; let finalizeComponentCode = `w${componentID}.destroy();`;
if (ref) { if (ref) {
finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`; finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`;
@@ -251,6 +246,7 @@ QWeb.addDirective({
finalizeComponentCode = `let finalize = () => { finalizeComponentCode = `let finalize = () => {
${finalizeComponentCode} ${finalizeComponentCode}
}; };
delete w${componentID}.__owl__.transitionInserted;
utils.transitionRemove(vn, '${transition}', finalize);`; utils.transitionRemove(vn, '${transition}', finalize);`;
} }
@@ -381,7 +377,7 @@ QWeb.addDirective({
} }
ctx.addLine( ctx.addLine(
`w${componentID}.__updateProps(props${componentID}, extra.fiber${scopeVars && `w${componentID}.__updateProps(props${componentID}, extra.fiber${scopeVars &&
", " + scopeVars}, sibling)${styleCode};` ", " + scopeVars})${styleCode};`
); );
ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`); ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`);
if (registerCode) { if (registerCode) {
@@ -409,6 +405,12 @@ QWeb.addDirective({
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}` `if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
); );
ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`); ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`);
if (transition) {
ctx.addLine(`const __patch${componentID} = w${componentID}.__patch;`);
ctx.addLine(
`w${componentID}.__patch = fiber => {__patch${componentID}.call(w${componentID}, fiber); if(!w${componentID}.__owl__.transitionInserted){w${componentID}.__owl__.transitionInserted = true;utils.transitionInsert(w${componentID}.__owl__.vnode, '${transition}');}};`
);
}
ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`); ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`);
if (hasSlots) { if (hasSlots) {
@@ -436,14 +438,13 @@ QWeb.addDirective({
} }
} }
ctx.addLine(`let def${defID} = w${componentID}.__prepare(extra.fiber, ${scopeVars}, sibling);`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM
ctx.addLine( ctx.addLine(
`let pvnode = h('dummy', {key: ${templateKey}, hook: {insert(vn) { let nvn=w${componentID}.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeComponentCode}}}});` `let fiber = w${componentID}.__prepare(extra.fiber, ${scopeVars}, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`
); );
ctx.addLine(`const fiber = w${componentID}.__owl__.currentFiber;`); // hack: specify empty remove hook to prevent the node from being removed from the DOM
const insertHook = refExpr ? `insert(vn) {${refExpr}},` : "";
ctx.addLine( ctx.addLine(
`def${defID}.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});` `let pvnode = h('dummy', {key: ${templateKey}, hook: {${insertHook}remove() {},destroy(vn) {${finalizeComponentCode}}}});`
); );
if (registerCode) { if (registerCode) {
ctx.addLine(registerCode); ctx.addLine(registerCode);
@@ -460,7 +461,6 @@ QWeb.addDirective({
} }
ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`); ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`);
ctx.addLine(`sibling = w${componentID}.__owl__.currentFiber || sibling;`);
return true; return true;
} }
+58 -41
View File
@@ -57,21 +57,24 @@ export class Fiber {
root: Fiber; root: Fiber;
child: Fiber | null = null; child: Fiber | null = null;
sibling: Fiber | null = null; sibling: Fiber | null = null;
lastChild: Fiber | null = null;
parent: Fiber | null = null; parent: Fiber | null = null;
error?: Error; error?: Error;
constructor(parent: Fiber | null, component: Component<any, any>, scope, vars, force, target) { constructor(parent: Fiber | null, component: Component<any, any>, force, target) {
this.force = force;
this.scope = scope;
this.vars = vars;
this.component = component; this.component = component;
this.force = force;
this.target = target; this.target = target;
const __owl__ = component.__owl__;
this.scope = __owl__.scope;
this.vars = __owl__.vars;
this.root = parent ? parent.root : this; this.root = parent ? parent.root : this;
this.parent = parent; this.parent = parent;
let oldFiber = component.__owl__.currentFiber; let oldFiber = __owl__.currentFiber;
if (oldFiber && !oldFiber.isCompleted) { if (oldFiber && !oldFiber.isCompleted) {
if (oldFiber.root === oldFiber && !parent) { if (oldFiber.root === oldFiber && !parent) {
// both oldFiber and this fiber are root fibers // both oldFiber and this fiber are root fibers
@@ -84,7 +87,7 @@ export class Fiber {
this.root.counter++; this.root.counter++;
component.__owl__.currentFiber = this; __owl__.currentFiber = this;
} }
/** /**
@@ -103,6 +106,7 @@ export class Fiber {
// remove relation to children // remove relation to children
oldFiber.child.parent = null; oldFiber.child.parent = null;
oldFiber.child = null; oldFiber.child = null;
oldFiber.lastChild = null;
} }
oldFiber.counter = 1; // re-initialize counter oldFiber.counter = 1; // re-initialize counter
oldFiber.id = Fiber.nextId++; oldFiber.id = Fiber.nextId++;
@@ -116,6 +120,7 @@ export class Fiber {
*/ */
_remapFiber(oldFiber: Fiber) { _remapFiber(oldFiber: Fiber) {
oldFiber.cancel(); oldFiber.cancel();
this.shouldPatch = oldFiber.shouldPatch;
if (oldFiber === oldFiber.root) { if (oldFiber === oldFiber.root) {
oldFiber.counter++; oldFiber.counter++;
} }
@@ -124,6 +129,9 @@ export class Fiber {
this.parent = oldFiber.parent; this.parent = oldFiber.parent;
this.root = this.parent.root; this.root = this.parent.root;
this.sibling = oldFiber.sibling; this.sibling = oldFiber.sibling;
if (this.parent.lastChild === oldFiber) {
this.parent.lastChild = this;
}
if (this.parent.child === oldFiber) { if (this.parent.child === oldFiber) {
this.parent.child = this; this.parent.child = this;
} else { } else {
@@ -171,54 +179,63 @@ export class Fiber {
* are ready, and the scheduler decides to process it. * are ready, and the scheduler decides to process it.
*/ */
complete() { complete() {
const component = this.component; let component = this.component;
if (this.target) {
component.__patch(this.vnode);
this.target.appendChild(component.el!);
if (document.body.contains(this.target)) {
component.__callMounted();
}
} else if (component.__owl__.isMounted && this === this.root) {
this.patchComponents();
}
this.isCompleted = true; this.isCompleted = true;
} if (!this.target && !component.__owl__.isMounted) {
return;
}
/** // build patchQueue
* Compute and apply the patch queue of the fiber.
* 1) Call 'willPatch' on the component of each patch
* 2) Call '__patch' on the component of each patch
* 3) Call 'patched' on the component of each patch, in reverse order
*/
patchComponents() {
const patchQueue: Fiber[] = []; const patchQueue: Fiber[] = [];
const doWork: (Fiber) => Fiber | null = function(f) { const doWork: (Fiber) => Fiber | null = function(f) {
if (f.shouldPatch) { patchQueue.push(f);
patchQueue.push(f); return f.child;
return f.child;
}
}; };
this._walk(doWork); this._walk(doWork);
let component: Component<any, any> = this.component;
const patchLen = patchQueue.length; const patchLen = patchQueue.length;
for (let i = 0; i < patchLen; i++) {
component = patchQueue[i].component; // call willPatch hook on each fiber of patchQueue
if (component.__owl__.willPatchCB) {
component.__owl__.willPatchCB();
}
component.willPatch();
}
for (let i = 0; i < patchLen; i++) { for (let i = 0; i < patchLen; i++) {
const fiber = patchQueue[i];
if (fiber.shouldPatch) {
component = fiber.component;
if (component.__owl__.willPatchCB) {
component.__owl__.willPatchCB();
}
component.willPatch();
}
}
// call __patch on each fiber of (reversed) patchQueue
for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i]; const fiber = patchQueue[i];
component = fiber.component; component = fiber.component;
component.__patch(fiber.vnode); component.__patch(fiber.vnode!);
if (!fiber.shouldPatch && (!fiber.target || i !== 0)) {
component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm;
}
component.__owl__.currentFiber = null; component.__owl__.currentFiber = null;
} }
// insert into the DOM (mount case)
let inDOM = false;
if (this.target) {
this.target.appendChild(this.component.el!);
inDOM = document.body.contains(this.target);
this.component.env.qweb.trigger("dom-appended");
}
// call patched/mounted hook on each fiber of (reversed) patchQueue
for (let i = patchLen - 1; i >= 0; i--) { for (let i = patchLen - 1; i >= 0; i--) {
component = patchQueue[i].component; const fiber = patchQueue[i];
component.patched(); component = fiber.component;
if (component.__owl__.patchedCB) { if (fiber.shouldPatch && !this.target) {
component.__owl__.patchedCB(); component.patched();
if (component.__owl__.patchedCB) {
component.__owl__.patchedCB();
}
} else if (this.target ? inDOM : true) {
component.__callMounted();
} }
} }
} }
+5 -2
View File
@@ -48,7 +48,7 @@ QWeb.utils.validateProps = function(Widget, props: Object) {
throw e; throw e;
} }
if (!isValid) { if (!isValid) {
throw new Error(`Props '${propName}' of invalid type in component '${Widget.name}'`); throw new Error(`Invalid Prop '${propName}' in component '${Widget.name}'`);
} }
} }
for (let propName in props) { for (let propName in props) {
@@ -89,7 +89,10 @@ function isValidProp(prop, propDef): boolean {
if (propDef.optional && prop === undefined) { if (propDef.optional && prop === undefined) {
return true; return true;
} }
let result = isValidProp(prop, propDef.type); let result = propDef.type ? isValidProp(prop, propDef.type) : true;
if (propDef.validate) {
result = result && propDef.validate(prop);
}
if (propDef.type === Array && propDef.element) { if (propDef.type === Array && propDef.element) {
for (let i = 0, iLen = prop.length; i < iLen; i++) { for (let i = 0, iLen = prop.length; i < iLen; i++) {
result = result && isValidProp(prop[i], propDef.element); result = result && isValidProp(prop[i], propDef.element);
+17 -5
View File
@@ -25,7 +25,19 @@ export class Scheduler {
this.requestAnimationFrame = requestAnimationFrame; this.requestAnimationFrame = requestAnimationFrame;
} }
start() {
this.isRunning = true;
this.scheduleTasks();
}
stop() {
this.isRunning = false;
}
addFiber(fiber): Promise<void> { addFiber(fiber): Promise<void> {
// if the fiber was remapped into a larger rendering fiber, it may not be a
// root fiber. But we only want to register root fibers
fiber = fiber.root;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (fiber.error) { if (fiber.error) {
return reject(fiber.error); return reject(fiber.error);
@@ -40,7 +52,7 @@ export class Scheduler {
} }
}); });
if (!this.isRunning) { if (!this.isRunning) {
this.scheduleTasks(); this.start();
} }
}); });
} }
@@ -71,16 +83,16 @@ export class Scheduler {
return true; return true;
}); });
this.tasks = tasks.concat(this.tasks); this.tasks = tasks.concat(this.tasks);
if (this.tasks.length === 0) {
this.stop();
}
} }
scheduleTasks() { scheduleTasks() {
this.isRunning = true;
this.requestAnimationFrame(() => { this.requestAnimationFrame(() => {
this.flush(); this.flush();
if (this.tasks.length > 0) { if (this.isRunning) {
this.scheduleTasks(); this.scheduleTasks();
} else {
this.isRunning = false;
} }
}); });
} }
+2 -2
View File
@@ -139,10 +139,10 @@ export function useContextWithCB(ctx: Context, component: Component<any, any>, m
} }
}); });
const __destroy = component.__destroy; const __destroy = component.__destroy;
component.__destroy = (parent) => { component.__destroy = parent => {
ctx.off("update", component); ctx.off("update", component);
delete mapping[id]; delete mapping[id];
__destroy.call(component, parent); __destroy.call(component, parent);
} };
return ctx.state; return ctx.state;
} }
+2 -1
View File
@@ -12,6 +12,7 @@ import * as _store from "./store";
import * as _utils from "./utils"; import * as _utils from "./utils";
import * as _tags from "./tags"; import * as _tags from "./tags";
import { AsyncRoot } from "./misc/async_root"; import { AsyncRoot } from "./misc/async_root";
import { Portal } from "./misc/portal";
import * as _hooks from "./hooks"; import * as _hooks from "./hooks";
import * as _context from "./context"; import * as _context from "./context";
import { Link } from "./router/link"; import { Link } from "./router/link";
@@ -29,7 +30,7 @@ export const router = { Router, RouteComponent, Link };
export const Store = _store.Store; export const Store = _store.Store;
export const utils = _utils; export const utils = _utils;
export const tags = _tags; export const tags = _tags;
export const misc = { AsyncRoot }; export const misc = { AsyncRoot, Portal };
export const hooks = Object.assign({}, _hooks, { export const hooks = Object.assign({}, _hooks, {
useContext: _context.useContext, useContext: _context.useContext,
useDispatch: _store.useDispatch, useDispatch: _store.useDispatch,
+156
View File
@@ -0,0 +1,156 @@
import { Component, portalSymbol } from "../component/component";
import { VNode, patch } from "../vdom/index";
import { xml } from "../tags";
import { OwlEvent } from "../core/owl_event";
import { useSubEnv } from "../hooks";
/**
* Portal
*
* The Portal component allows to render a part of a component outside it's DOM.
* It is for example useful for dialogs: for css reasons, dialogs are in general
* placed in a specific spot of the DOM (e.g. directly in the body). With the
* Portal, a component can conditionally specify in its tempate that it contains
* a dialog, and where this dialog should be inserted in the DOM.
*
* The Portal component ensures that the communication between the content of
* the Portal and its parent properly works: business events reaching the Portal
* are re-triggered on an empty <portal> node located in the parent's DOM.
*/
export class Portal extends Component<any, any> {
static template = xml`<portal><t t-slot="default"/></portal>`;
static props = {
target: {
type: String
}
};
// boolean to indicate whether or not we must listen to 'dom-appended' event
// to hook on the moment when the target is inserted into the DOM (because it
// is not when the portal is rendered)
doTargetLookUp: boolean = true;
// set of encountered events that need to be redirected
_handledEvents: Set<string> = new Set();
// function that will be the event's tunnel (needs to be an arrow function to
// avoid having to rebind `this`)
_handlerTunnel: (f: OwlEvent<any>) => void = (ev: OwlEvent<any>) => {
ev.stopPropagation();
this.__trigger(ev.originalComponent, ev.type, ev.detail);
};
// Storing the parent's env
parentEnv: any = null;
// represents the element that is moved somewhere else
portal: VNode | null = null;
// the target where we will move `portal`
target: HTMLElement | null = null;
constructor(parent, props) {
super(parent, props);
this.parentEnv = parent ? parent.env : {};
// put a callback in the env that is propagated to children s.t. portal can
// register an handler to those events just before children will trigger them
useSubEnv({
[portalSymbol]: ev => {
if (!this._handledEvents.has(ev.type)) {
this.portal!.elm!.addEventListener(ev.type, this._handlerTunnel);
this._handledEvents.add(ev.type);
}
}
});
}
/**
* At each DOM change, we must ensure that the portal contains exactly one
* child
*/
__checkVNodeStructure(vnode: VNode) {
const children = vnode.children!;
let countRealNodes = 0;
for (let child of children) {
if ((child as VNode).sel) {
countRealNodes++;
}
}
if (countRealNodes !== 1) {
throw new Error(`Portal must have exactly one non-text child (has ${countRealNodes})`);
}
}
/**
* Ensure the target is still there at whichever time we render
*/
__checkTargetPresence() {
if (!this.target || !document.contains(this.target)) {
throw new Error(`Could not find any match for "${this.props.target}"`);
}
}
/**
* Move the portal's element to the target
*/
__deployPortal() {
this.__checkTargetPresence();
this.target!.appendChild(this.portal!.elm!);
}
/**
* Override to remove from the DOM the element we have teleported
*
* @override
*/
__destroy(parent) {
if (this.portal && this.portal.elm) {
const displacedElm = this.portal.elm!;
const parent = displacedElm.parentNode;
if (parent) {
parent.removeChild(displacedElm);
}
}
super.__destroy(parent);
}
/**
* Override to patch the element that has been teleported
*
* @override
*/
__patch(vnode) {
if (this.doTargetLookUp) {
const target = document.querySelector(this.props.target);
if (!target) {
this.env.qweb.on("dom-appended", this, () => {
this.doTargetLookUp = false;
this.env.qweb.off("dom-appended", this);
this.target = document.querySelector(this.props.target);
this.__deployPortal();
});
} else {
this.doTargetLookUp = false;
this.target = target;
}
}
this.__checkVNodeStructure(vnode);
const shouldDeploy = !this.portal && !this.doTargetLookUp;
if (!this.doTargetLookUp && !shouldDeploy) {
// Only on pure patching, provided the
// this.target's parent has not been unmounted
this.__checkTargetPresence();
}
const portalPatch = this.portal ? this.portal : document.createElement(vnode.children[0].sel);
this.portal = patch(portalPatch, vnode.children![0] as VNode);
vnode.children = [];
super.__patch(vnode);
if (shouldDeploy) {
this.__deployPortal();
}
}
/**
* Override to set the env
*/
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
const env = this.env;
this.env = this.parentEnv;
super.__trigger(component, eventType, payload);
this.env = env;
}
}
+6 -5
View File
@@ -28,9 +28,12 @@ QWeb.utils.getFragment = function(str: string): DocumentFragment {
QWeb.utils.htmlToVDOM = htmlToVDOM; QWeb.utils.htmlToVDOM = htmlToVDOM;
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) { function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) {
if (value === "0" && ctx.caller) { if (value === "0") {
qweb._compileNode(ctx.caller, ctx); const caller = ctx.getCaller();
return; if (caller) {
qweb._compileNode(caller, ctx.getInliningContext());
return;
}
} }
if (value.xml instanceof NodeList && !value.id) { if (value.xml instanceof NodeList && !value.id) {
@@ -192,11 +195,9 @@ QWeb.addDirective({
// extract variables from nodecopy // extract variables from nodecopy
const tempCtx = new CompilationContext(); const tempCtx = new CompilationContext();
tempCtx.nextID = ctx.rootContext.nextID;
tempCtx.allowMultipleRoots = true; tempCtx.allowMultipleRoots = true;
qweb._compileNode(nodeCopy, tempCtx); qweb._compileNode(nodeCopy, tempCtx);
const vars = Object.assign({}, ctx.variables, tempCtx.variables); const vars = Object.assign({}, ctx.variables, tempCtx.variables);
ctx.rootContext.nextID = tempCtx.nextID;
const templateMap = Object.create(ctx.templates); const templateMap = Object.create(ctx.templates);
// open new scope, if necessary // open new scope, if necessary
+31 -10
View File
@@ -6,7 +6,7 @@ export const INTERP_REGEXP = /\{\{.*?\}\}/g;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
export class CompilationContext { export class CompilationContext {
nextID: number = 1; static nextID: number = 1;
code: string[] = []; code: string[] = [];
variables: { [key: string]: QWebVar } = {}; variables: { [key: string]: QWebVar } = {};
escaping: boolean = false; escaping: boolean = false;
@@ -22,7 +22,6 @@ export class CompilationContext {
shouldDefineUtils: boolean = false; shouldDefineUtils: boolean = false;
shouldDefineRefs: boolean = false; shouldDefineRefs: boolean = false;
shouldDefineResult: boolean = true; shouldDefineResult: boolean = true;
shouldDefineSibling: boolean = true;
shouldProtectContext: boolean = false; shouldProtectContext: boolean = false;
shouldTrackScope: boolean = false; shouldTrackScope: boolean = false;
loopNumber: number = 0; loopNumber: number = 0;
@@ -32,8 +31,9 @@ export class CompilationContext {
hasParentWidget: boolean = false; hasParentWidget: boolean = false;
scopeVars: any[] = []; scopeVars: any[] = [];
currentKey: string = ""; currentKey: string = "";
lastNodeKey: string = ""; // temp variable to communicate to previous caller
templates: { [key: string]: boolean } = {}; templates: { [key: string]: boolean } = {};
callingLevel: number = 0;
inliningLevel: number = 0;
constructor(name?: string) { constructor(name?: string) {
this.rootContext = this; this.rootContext = this;
@@ -43,8 +43,7 @@ export class CompilationContext {
} }
generateID(): number { generateID(): number {
const id = this.rootContext.nextID++; return CompilationContext.nextID++;
return id;
} }
/** /**
@@ -61,8 +60,8 @@ export class CompilationContext {
for (let i = 0; i < this.loopNumber - 1; i++) { for (let i = 0; i < this.loopNumber - 1; i++) {
locationExpr += `\${i${i + 1}}__`; locationExpr += `\${i${i + 1}}__`;
} }
if (this.lastNodeKey || this.currentKey) { if (this.currentKey) {
const k = this.lastNodeKey || this.currentKey; const k = this.currentKey;
this.addLine(`let k${id} = ${locationExpr}\` + ${k};`); this.addLine(`let k${id} = ${locationExpr}\` + ${k};`);
} else { } else {
locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`"; locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`";
@@ -88,9 +87,6 @@ export class CompilationContext {
if (this.shouldDefineResult) { if (this.shouldDefineResult) {
this.code.unshift(" let result;"); this.code.unshift(" let result;");
} }
if (this.shouldDefineSibling) {
this.code.unshift(" let sibling = null;");
}
if (this.shouldDefineRefs) { if (this.shouldDefineRefs) {
this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};"); this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};");
} }
@@ -135,6 +131,10 @@ export class CompilationContext {
subContext(key: keyof CompilationContext, value: any): CompilationContext { subContext(key: keyof CompilationContext, value: any): CompilationContext {
const newContext = Object.create(this); const newContext = Object.create(this);
newContext[key] = value; newContext[key] = value;
if (key === "caller") {
newContext.callingLevel++;
newContext.inliningLevel++;
}
return newContext; return newContext;
} }
@@ -172,6 +172,27 @@ export class CompilationContext {
this.dedent(); this.dedent();
this.addLine("}"); this.addLine("}");
} }
/**
* Recursively (inverse) fetches the `caller` of a context
* Useful to determine to which t-call a t-raw="0" refers
*/
getCaller(targetLevel?: number): Element | null {
if (targetLevel === undefined) {
targetLevel = this.inliningLevel;
}
if (targetLevel === this.callingLevel) {
return this.caller || null;
}
const proto = (this as any).__proto__;
return proto ? proto.getCaller(targetLevel) : null;
}
/**
* Marks the context with the current recursive level
* in which we are for inlining archs (t-raw="0")
*/
getInliningContext(): CompilationContext {
return this.subContext("inliningLevel", this.inliningLevel - 1);
}
getValue(val: any): QWebVar | string { getValue(val: any): QWebVar | string {
return val in this.variables ? this.getValue(this.variables[val]) : val; return val in this.variables ? this.getValue(this.variables[val]) : val;
+8 -3
View File
@@ -105,6 +105,8 @@ QWeb.utils.transitionInsert = function(vn: VNode, name: string) {
elm.classList.add(name + "-enter"); elm.classList.add(name + "-enter");
elm.classList.add(name + "-enter-active"); elm.classList.add(name + "-enter-active");
elm.classList.remove(name + "-leave-active");
elm.classList.remove(name + "-leave-to");
const finalize = () => { const finalize = () => {
elm.classList.remove(name + "-enter-active"); elm.classList.remove(name + "-enter-active");
elm.classList.remove(name + "-enter-to"); elm.classList.remove(name + "-enter-to");
@@ -123,6 +125,9 @@ QWeb.utils.transitionRemove = function(vn: VNode, name: string, rm: () => void)
elm.classList.add(name + "-leave"); elm.classList.add(name + "-leave");
elm.classList.add(name + "-leave-active"); elm.classList.add(name + "-leave-active");
const finalize = () => { const finalize = () => {
if (!elm.classList.contains(name + "-leave-active")) {
return;
}
elm.classList.remove(name + "-leave-active"); elm.classList.remove(name + "-leave-active");
elm.classList.remove(name + "-leave-to"); elm.classList.remove(name + "-leave-to");
rm(); rm();
@@ -201,12 +206,12 @@ QWeb.addDirective({
if (!ctx.parentNode) { if (!ctx.parentNode) {
ctx.rootContext.shouldDefineResult = true; ctx.rootContext.shouldDefineResult = true;
ctx.rootContext.shouldDefineUtils = true; ctx.rootContext.shouldDefineUtils = true;
parentNode = `children${ctx.nextID++}`; parentNode = `children${ctx.generateID()}`;
ctx.addLine(`let ${parentNode}= []`); ctx.addLine(`let ${parentNode}= []`);
ctx.addLine(`result = {}`); ctx.addLine(`result = {}`);
} }
ctx.addLine( ctx.addLine(
`slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: ${parentNode}, vars: extra.vars, parent: owner}));` `slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: ${parentNode}, vars: extra.vars, parent: extra.parent || owner}));`
); );
if (!ctx.parentNode) { if (!ctx.parentNode) {
ctx.addLine(`utils.defineProxy(result, ${parentNode}[0]);`); ctx.addLine(`utils.defineProxy(result, ${parentNode}[0]);`);
@@ -279,6 +284,6 @@ QWeb.addDirective({
atNodeEncounter({ ctx, value }) { atNodeEncounter({ ctx, value }) {
let id = ctx.generateID(); let id = ctx.generateID();
ctx.addLine(`const nodeKey${id} = ${ctx.formatExpression(value)};`); ctx.addLine(`const nodeKey${id} = ${ctx.formatExpression(value)};`);
ctx.lastNodeKey = `nodeKey${id}`; ctx.currentKey = `nodeKey${id}`;
} }
}); });
+10 -6
View File
@@ -362,8 +362,7 @@ export class QWeb extends EventBus {
if (parentContext) { if (parentContext) {
ctx.templates = Object.create(parentContext.templates); ctx.templates = Object.create(parentContext.templates);
ctx.variables = Object.create(parentContext.variables); ctx.variables = Object.create(parentContext.variables);
ctx.nextID = parentContext.nextID + 1; ctx.parentNode = parentContext.parentNode || ctx.generateID();
ctx.parentNode = parentContext.parentNode || ctx.nextID++;
ctx.allowMultipleRoots = true; ctx.allowMultipleRoots = true;
ctx.hasParentWidget = true; ctx.hasParentWidget = true;
ctx.shouldDefineResult = false; ctx.shouldDefineResult = false;
@@ -455,13 +454,19 @@ export class QWeb extends EventBus {
return; return;
} }
if (ctx !== ctx.rootContext) {
ctx = ctx.subContext("currentKey", ctx.currentKey);
}
const firstLetter = node.tagName[0]; const firstLetter = node.tagName[0];
if (firstLetter === firstLetter.toUpperCase()) { if (firstLetter === firstLetter.toUpperCase()) {
// this is a component, we modify in place the xml document to change // this is a component, we modify in place the xml document to change
// <SomeComponent ... /> to <t t-component="SomeComponent" ... /> // <SomeComponent ... /> to <t t-component="SomeComponent" ... />
node.setAttribute("t-component", node.tagName); node.setAttribute("t-component", node.tagName);
} else if (node.tagName !== 't' && node.hasAttribute('t-component')) { } else if (node.tagName !== "t" && node.hasAttribute("t-component")) {
throw new Error(`Directive 't-component' can only be used on <t> nodes (used on a <${node.tagName}>)`); throw new Error(
`Directive 't-component' can only be used on <t> nodes (used on a <${node.tagName}>)`
);
} }
const attributes = (<Element>node).attributes; const attributes = (<Element>node).attributes;
@@ -542,7 +547,6 @@ export class QWeb extends EventBus {
if (node.nodeName !== "t") { if (node.nodeName !== "t") {
let nodeID = this._compileGenericNode(node, ctx, withHandlers); let nodeID = this._compileGenericNode(node, ctx, withHandlers);
ctx = ctx.withParent(nodeID); ctx = ctx.withParent(nodeID);
ctx = ctx.subContext("currentKey", ctx.lastNodeKey);
let nodeHooks = {}; let nodeHooks = {};
let addNodeHook = function(hook, handler) { let addNodeHook = function(hook, handler) {
nodeHooks[hook] = nodeHooks[hook] || []; nodeHooks[hook] = nodeHooks[hook] || [];
@@ -726,7 +730,7 @@ export class QWeb extends EventBus {
} }
} }
let nodeID = ctx.generateID(); let nodeID = ctx.generateID();
let nodeKey = ctx.lastNodeKey || nodeID; let nodeKey = ctx.currentKey || nodeID;
const parts = [`key:${nodeKey}`]; const parts = [`key:${nodeKey}`];
if (attrs.length + tattrs.length > 0) { if (attrs.length + tattrs.length > 0) {
parts.push(`attrs:{${attrs.join(",")}}`); parts.push(`attrs:{${attrs.join(",")}}`);
+27 -15
View File
@@ -77,41 +77,45 @@ export class Store extends Context {
interface SelectorOptions { interface SelectorOptions {
store?: Store; store?: Store;
isEqual?: (a: any, b: any) => boolean; isEqual?: (a: any, b: any) => boolean;
onUpdate?: (result: any) => any;
} }
const isStrictEqual = (a, b) => a === b; const isStrictEqual = (a, b) => a === b;
export function useStore(selector, options: SelectorOptions = {}): any { export function useStore(selector, options: SelectorOptions = {}): any {
const component: Component<any, any> = Component.current!; const component: Component<any, any> = Component.current!;
const componentId = component.__owl__.id;
const store = options.store || (component.env.store as Store); const store = options.store || (component.env.store as Store);
if (!(store instanceof Store)) { if (!(store instanceof Store)) {
throw new Error(`No store found when connecting '${component.constructor.name}'`); throw new Error(`No store found when connecting '${component.constructor.name}'`);
} }
let result = selector(store.state, component.props); let result = selector(store.state, component.props);
const hashFn = store.observer.revNumber.bind(store.observer); const hashFn = store.observer.revNumber.bind(store.observer);
let revNumber = hashFn(result) || result; let revNumber = hashFn(result);
const isEqual = options.isEqual || isStrictEqual; const isEqual = options.isEqual || isStrictEqual;
if (!store.updateFunctions[component.__owl__.id]) { if (!store.updateFunctions[componentId]) {
store.updateFunctions[component.__owl__.id] = []; store.updateFunctions[componentId] = [];
} }
const updateFunctions = store.updateFunctions[component.__owl__.id]; function selectCompareUpdate(state, props): boolean {
updateFunctions.push(function(): boolean {
const oldResult = result; const oldResult = result;
result = selector(store!.state, component.props); result = selector(state, props);
const newRevNumber = hashFn(result); const newRevNumber = hashFn(result);
if ( if ((newRevNumber > 0 && revNumber !== newRevNumber) || !isEqual(oldResult, result)) {
(newRevNumber > 0 && revNumber !== newRevNumber) ||
(newRevNumber === 0 && !isEqual(oldResult, result))
) {
revNumber = newRevNumber; revNumber = newRevNumber;
if (options.onUpdate) {
options.onUpdate(result);
}
return true; return true;
} }
return false; return false;
}
store.updateFunctions[componentId].push(function(): boolean {
return selectCompareUpdate(store!.state, component.props);
}); });
useContextWithCB(store, component, function(): Promise<void> | void { useContextWithCB(store, component, function(): Promise<void> | void {
let shouldRender = false; let shouldRender = false;
for (let fn of updateFunctions) { for (let fn of store.updateFunctions[componentId]) {
shouldRender = fn() || shouldRender; shouldRender = fn() || shouldRender;
} }
if (shouldRender) { if (shouldRender) {
@@ -119,16 +123,24 @@ export function useStore(selector, options: SelectorOptions = {}): any {
} }
}); });
onWillUpdateProps(props => { onWillUpdateProps(props => {
delete store.updateFunctions[component.__owl__.id]; selectCompareUpdate(store.state, props);
result = selector(store.state, props);
}); });
const __destroy = component.__destroy;
component.__destroy = parent => {
delete store.updateFunctions[componentId];
__destroy.call(component, parent);
};
if (typeof result !== "object") {
return result;
}
return new Proxy(result, { return new Proxy(result, {
get(target, k) { get(target, k) {
return result[k]; return result[k];
}, },
set(target, k, v) { set(target, k, v) {
result[k] = v; throw new Error("Store state should only be modified through actions");
return true;
} }
}); });
} }
+7 -5
View File
@@ -101,7 +101,7 @@ function isVnode(vnode: any): vnode is VNode {
type KeyToIndexMap = { [key: string]: number }; type KeyToIndexMap = { [key: string]: number };
type ArraysOf<T> = { [K in keyof T]: (T[K])[] }; type ArraysOf<T> = { [K in keyof T]: T[K][] };
type ModuleHooks = ArraysOf<Module>; type ModuleHooks = ArraysOf<Module>;
@@ -176,10 +176,12 @@ export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
} }
vnode.elm = api.createComment(vnode.text as string); vnode.elm = api.createComment(vnode.text as string);
} else if (sel !== undefined) { } else if (sel !== undefined) {
const elm = (vnode.elm = const elm =
isDef(data) && isDef((i = (data as VNodeData).ns)) vnode.elm ||
? api.createElementNS(i, sel) (vnode.elm =
: api.createElement(sel)); isDef(data) && isDef((i = (data as VNodeData).ns))
? api.createElementNS(i, sel)
: api.createElement(sel));
for (i = 0, iLen = cbs.create.length; i < iLen; ++i) cbs.create[i](emptyNode, vnode); for (i = 0, iLen = cbs.create.length; i < iLen; ++i) cbs.create[i](emptyNode, vnode);
if (array(children)) { if (array(children)) {
for (i = 0, iLen = children.length; i < iLen; ++i) { for (i = 0, iLen = children.length; i < iLen; ++i) {
+90 -48
View File
@@ -7,40 +7,39 @@ exports[`animations t-transition combined with component 1`] = `
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let owner = context; let owner = context;
let sibling = null;
var h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
//COMPONENT //COMPONENT
let k4 = \`__5__\`; let k3 = \`__4__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false; let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
let props3 = {}; let props2 = {};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) { if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w3.destroy(); w2.destroy();
w3 = false; w2 = false;
} }
if (w3) { if (w2) {
w3.__updateProps(props3, extra.fiber, undefined, undefined, sibling); w2.__updateProps(props2, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode; let pvnode = w2.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey3 = \`Child\`; let componentKey2 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child']; let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')} if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w3 = new W3(parent, props3); w2 = new W2(parent, props2);
parent.__owl__.cmap[k4] = w3.__owl__.id; const __patch2 = w2.__patch;
let def2 = w3.__prepare(extra.fiber, undefined, undefined, sibling); w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
let pvnode = h('dummy', {key: k4, hook: {insert(vn) { let nvn=w3.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => { parent.__owl__.cmap[k3] = w2.__owl__.id;
w3.destroy(); let fiber = w2.__prepare(extra.fiber, undefined, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy();
}; };
delete w2.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}}); utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w3.__owl__.pvnode = pvnode; w2.__owl__.pvnode = pvnode;
} }
w3.__owl__.parentLastFiberId = extra.fiber.id; w2.__owl__.parentLastFiberId = extra.fiber.id;
sibling = w3.__owl__.currentFiber || sibling;
return vn1; return vn1;
}" }"
`; `;
@@ -52,41 +51,86 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let owner = context; let owner = context;
let sibling = null;
var h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
if (context['state'].display) { if (context['state'].display) {
//COMPONENT //COMPONENT
let k4 = \`__5__\`; let k3 = \`__4__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false; let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
let props3 = {}; let props2 = {};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) { if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w3.destroy(); w2.destroy();
w3 = false; w2 = false;
} }
if (w3) { if (w2) {
w3.__updateProps(props3, extra.fiber, undefined, undefined, sibling); w2.__updateProps(props2, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode; let pvnode = w2.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey3 = \`Child\`; let componentKey2 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child']; let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')} if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w3 = new W3(parent, props3); w2 = new W2(parent, props2);
parent.__owl__.cmap[k4] = w3.__owl__.id; const __patch2 = w2.__patch;
let def2 = w3.__prepare(extra.fiber, undefined, undefined, sibling); w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
let pvnode = h('dummy', {key: k4, hook: {insert(vn) { let nvn=w3.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => { parent.__owl__.cmap[k3] = w2.__owl__.id;
w3.destroy(); let fiber = w2.__prepare(extra.fiber, undefined, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy();
}; };
delete w2.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}}); utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w3.__owl__.pvnode = pvnode; w2.__owl__.pvnode = pvnode;
} }
w3.__owl__.parentLastFiberId = extra.fiber.id; w2.__owl__.parentLastFiberId = extra.fiber.id;
sibling = w3.__owl__.currentFiber || sibling; }
return vn1;
}"
`;
exports[`animations t-transition combined with t-component, remove and re-add before transitionend 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['state'].flag) {
//COMPONENT
let k3 = \`__4__\`;
let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, undefined, undefined);
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Child\`;
let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| context['Child'];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
const __patch2 = w2.__patch;
w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap[k3] = w2.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy();
};
delete w2.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
} }
return vn1; return vn1;
}" }"
@@ -96,7 +140,6 @@ exports[`animations t-transition with no delay/duration 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
let utils = this.constructor.utils; let utils = this.constructor.utils;
let sibling = null;
var h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1); var vn1 = h('span', p1, c1);
@@ -117,7 +160,6 @@ exports[`animations t-transition, on a simple node (insert) 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
let utils = this.constructor.utils; let utils = this.constructor.utils;
let sibling = null;
var h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1); var vn1 = h('span', p1, c1);
+74 -25
View File
@@ -1,13 +1,15 @@
import { Component, Env } from "../src/component/component"; import { Component, Env } from "../src/component/component";
import { QWeb } from "../src/qweb/index"; import { QWeb } from "../src/qweb/index";
import { useState, useRef } from "../src/hooks"; import { useState, useRef } from "../src/hooks";
import { xml } from "../src/tags";
import { import {
makeDeferred, makeDeferred,
makeTestFixture, makeTestFixture,
makeTestEnv, makeTestEnv,
patchNextFrame, patchNextFrame,
renderToDOM, renderToDOM,
unpatchNextFrame unpatchNextFrame,
nextTick
} from "./helpers"; } from "./helpers";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -252,11 +254,11 @@ describe("animations", () => {
widget.state.display = false; widget.state.display = false;
patchNextFrame(cb => { patchNextFrame(cb => {
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave chimay-leave-active" data-owl-key="__5__">blue</span></div>' '<div><span class="chimay-leave chimay-leave-active" data-owl-key="__4__">blue</span></div>'
); );
cb(); cb();
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__5__">blue</span></div>' '<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__4__">blue</span></div>'
); );
def.resolve(); def.resolve();
}); });
@@ -321,30 +323,23 @@ describe("animations", () => {
}); });
test("t-transition combined with t-component, remove and re-add before transitionend", async () => { test("t-transition combined with t-component, remove and re-add before transitionend", async () => {
expect.assertions(11); expect.assertions(12);
env.qweb.addTemplates( class Child extends Widget {
`<templates> static template = xml`<span>blue</span>`;
<div t-name="Parent"> }
<button t-on-click="toggle">Toggle</button>
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
</div>
<span t-name="Child">blue</span>
</templates>`
);
class Child extends Widget {}
class Parent extends Widget { class Parent extends Widget {
static template = xml`
<div t-name="Parent">
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
</div>`;
static components = { Child }; static components = { Child };
state = useState({ flag: false }); state = useState({ flag: false });
toggle() {
this.state.flag = !this.state.flag;
}
} }
const widget = new Parent(); const widget = new Parent();
await widget.mount(fixture); await widget.mount(fixture);
let button = widget.el!.querySelector("button"); expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot();
let def = makeDeferred(); let def = makeDeferred();
let phase = "enter"; let phase = "enter";
@@ -357,24 +352,78 @@ describe("animations", () => {
def.resolve(); def.resolve();
}); });
// click display the span // display the span
button!.click(); widget.state.flag = true;
await def; // wait for the mocked repaint to be done await def; // wait for the mocked repaint to be done
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition
expect(fixture.innerHTML).toBe('<div><button>Toggle</button><span class="">blue</span></div>'); expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
// click to remove the span, and click again to re-add it before transitionend // click to remove the span, and click again to re-add it before transitionend
def = makeDeferred(); def = makeDeferred();
phase = "leave"; phase = "leave";
button!.click();
widget.state.flag = false;
await def; // wait for the mocked repaint to be done await def; // wait for the mocked repaint to be done
def = makeDeferred(); def = makeDeferred();
phase = "enter"; phase = "enter";
button!.click(); widget.state.flag = true;
await def; // wait for the mocked repaint to be done await def; // wait for the mocked repaint to be done
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition
expect(fixture.innerHTML).toBe('<div><button>Toggle</button><span class="">blue</span></div>'); expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__4__">blue</span></div>');
});
test("transitionInsert is called the correct amount of times", async () => {
const oldTransitionInsert = QWeb.utils.transitionInsert;
QWeb.utils.transitionInsert = jest.fn(oldTransitionInsert);
class Child extends Widget {
static template = xml`<span>blue</span>`;
}
class Parent extends Widget {
static template = xml`
<div t-name="Parent">
<Child t-if="state.flag" t-transition="chimay"/>
</div>`;
static components = { Child };
state = useState({ flag: false });
}
patchNextFrame(cb => cb());
const widget = new Parent();
await widget.mount(fixture);
widget.state.flag = true;
await nextTick();
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__4__">blue</span></div>'
);
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="chimay-enter-active chimay-enter-to" data-owl-key="__4__">blue</span></div>'
);
expect(QWeb.utils.transitionInsert).toBeCalledTimes(2);
widget.state.flag = false;
await nextTick();
widget.state.flag = true;
await nextTick();
expect(QWeb.utils.transitionInsert).toBeCalledTimes(3);
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__4__">blue</span></div>');
QWeb.utils.transitionInsert = oldTransitionInsert;
}); });
}); });
File diff suppressed because it is too large Load Diff
@@ -7,37 +7,33 @@ exports[`props validation props are validated in dev mode (code snapshot) 1`] =
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let owner = context; let owner = context;
let sibling = null;
var h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
//COMPONENT //COMPONENT
let k4 = \`__5__\`; let k3 = \`__4__\`;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false; let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
let props3 = {message:1}; let props2 = {message:1};
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) { if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w3.destroy(); w2.destroy();
w3 = false; w2 = false;
} }
if (w3) { if (w2) {
w3.__updateProps(props3, extra.fiber, undefined, undefined, sibling); w2.__updateProps(props2, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode; let pvnode = w2.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey3 = \`Child\`; let componentKey2 = \`Child\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child']; let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| context['Child'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')} if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w3 = new W3(parent, props3); w2 = new W2(parent, props2);
parent.__owl__.cmap[k4] = w3.__owl__.id; parent.__owl__.cmap[k3] = w2.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined, sibling); let fiber = w2.__prepare(extra.fiber, undefined, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k4, hook: {insert(vn) { let nvn=w3.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w3.destroy();}}}); let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {w2.destroy();}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w3.__owl__.pvnode = pvnode; w2.__owl__.pvnode = pvnode;
} }
w3.__owl__.parentLastFiberId = extra.fiber.id; w2.__owl__.parentLastFiberId = extra.fiber.id;
sibling = w3.__owl__.currentFiber || sibling;
return vn1; return vn1;
}" }"
`; `;
+439 -20
View File
@@ -91,6 +91,17 @@ describe("basic widget properties", () => {
expect(fixture.innerHTML).toBe("<div>content</div>"); expect(fixture.innerHTML).toBe("<div>content</div>");
}); });
test("can be mounted on a documentFragment", async () => {
class SomeWidget extends Component<any, any> {
static template = xml`<div>content</div>`;
}
const widget = new SomeWidget();
await widget.mount(document.createDocumentFragment());
expect(fixture.innerHTML).toBe("");
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>content</div>");
});
test("display a nice message if mounted on a non existing node", async () => { test("display a nice message if mounted on a non existing node", async () => {
class SomeWidget extends Component<any, any> { class SomeWidget extends Component<any, any> {
static template = xml`<div>content</div>`; static template = xml`<div>content</div>`;
@@ -325,6 +336,26 @@ describe("basic widget properties", () => {
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div><span>1</span></div><div><span>2</span></div></div>"); expect(fixture.innerHTML).toBe("<div><div><span>1</span></div><div><span>2</span></div></div>");
}); });
test("t-key on a component with t-if, and a sibling component", async () => {
class Child extends Component<any, any> {
static template = xml`<span>child</span>`;
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<Child t-if="false" t-key="'str'"/>
<Child/>
</div>`;
static components = { Child };
}
const widget = new Parent();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>child</span></div>");
expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot();
});
}); });
describe("lifecycle hooks", () => { describe("lifecycle hooks", () => {
@@ -1351,7 +1382,13 @@ describe("composition", () => {
class SubWidget extends Widget {} class SubWidget extends Widget {}
class Parent extends Widget { class Parent extends Widget {
static components = { SubWidget }; static components = { SubWidget };
state = useState({ blips: [{ a: "a", id: 1 }, { b: "b", id: 2 }, { c: "c", id: 4 }] }); state = useState({
blips: [
{ a: "a", id: 1 },
{ b: "b", id: 2 },
{ c: "c", id: 4 }
]
});
} }
const parent = new Parent(); const parent = new Parent();
await parent.mount(fixture); await parent.mount(fixture);
@@ -1430,7 +1467,9 @@ describe("composition", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Directive 't-component' can only be used on <t> nodes (used on a <div>)`); expect(error.message).toBe(
`Directive 't-component' can only be used on <t> nodes (used on a <div>)`
);
}); });
test("sub components, loops, and shouldUpdate", async () => { test("sub components, loops, and shouldUpdate", async () => {
@@ -1452,7 +1491,11 @@ describe("composition", () => {
</t> </t>
</div>`; </div>`;
state = useState({ state = useState({
records: [{ id: 1, val: 1 }, { id: 2, val: 2 }, { id: 3, val: 3 }] records: [
{ id: 1, val: 1 },
{ id: 2, val: 2 },
{ id: 3, val: 3 }
]
}); });
static components = { ChildWidget }; static components = { ChildWidget };
} }
@@ -1470,6 +1513,25 @@ describe("composition", () => {
"<div><span>11</span><span>2</span><span>13</span></div>" "<div><span>11</span><span>2</span><span>13</span></div>"
); );
}); });
test("three level of components with collapsing root nodes", async () => {
class GrandChild extends Component<any, any> {
static template = xml`<div>2</div>`;
}
class Child extends Component<any, any> {
static components = { GrandChild };
static template = xml`<GrandChild/>`;
}
class Parent extends Component<any, any> {
static components = { Child };
static template = xml`<Child></Child>`;
}
const app = new Parent();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>2</div>");
});
}); });
describe("props evaluation ", () => { describe("props evaluation ", () => {
@@ -1757,7 +1819,6 @@ describe("class and style attributes with t-component", () => {
expect(error.message).toBe("Cannot read property 'crash' of undefined"); expect(error.message).toBe("Cannot read property 'crash' of undefined");
expect(fixture.innerHTML).toBe(""); expect(fixture.innerHTML).toBe("");
}); });
}); });
describe("other directives with t-component", () => { describe("other directives with t-component", () => {
@@ -1788,6 +1849,38 @@ describe("other directives with t-component", () => {
expect(widget.n).toBe(1); expect(widget.n).toBe(1);
}); });
test("t-on works, even if flush is called many times", async () => {
let flag = false;
let mounted = false;
class Child extends Component<any, any> {
static template = xml`<span>child</span>`;
mounted() {
mounted = true;
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-on-click="doSomething"/></div>`;
static components = { Child };
doSomething() {
flag = true;
}
}
const parent = new Parent();
parent.mount(fixture);
while (!mounted) {
await nextMicroTick();
Component.scheduler.flush();
}
expect(fixture.innerHTML).toBe("<div><span>child</span></div>");
expect(flag).toBe(false);
fixture.querySelector("span")!.click();
expect(flag).toBe(true);
});
test("t-on with handler bound to argument", async () => { test("t-on with handler bound to argument", async () => {
expect.assertions(3); expect.assertions(3);
env.qweb.addTemplates(` env.qweb.addTemplates(`
@@ -2094,6 +2187,40 @@ describe("other directives with t-component", () => {
expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot(); expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot();
}); });
test("t-on on nested components with collapsing root nodes", async () => {
const steps: string[] = [];
let grandChild;
class GrandChild extends Component<any, any> {
static template = xml`<span t-on-ev="_onEv"/>`;
constructor() {
super(...arguments);
grandChild = this;
}
_onEv() {
steps.push("GrandChild");
}
}
class Child extends Component<any, any> {
static template = xml`<GrandChild t-on-ev="_onEv"/>`;
static components = { GrandChild };
_onEv() {
steps.push("Child");
}
}
class Parent extends Component<any, any> {
static template = xml`<Child t-on-ev="_onEv"/>`;
static components = { Child };
_onEv() {
steps.push("Parent");
}
}
const parent = new Parent();
await parent.mount(fixture);
grandChild.trigger("ev");
expect(steps).toEqual(["GrandChild", "Child", "Parent"]);
});
test("t-if works with t-component", async () => { test("t-if works with t-component", async () => {
env.qweb.addTemplate("ParentWidget", `<div><t t-component="child" t-if="state.flag"/></div>`); env.qweb.addTemplate("ParentWidget", `<div><t t-component="child" t-if="state.flag"/></div>`);
class Child extends Widget {} class Child extends Widget {}
@@ -2194,6 +2321,36 @@ describe("other directives with t-component", () => {
await nextTick(); await nextTick();
expect(normalize(fixture.innerHTML)).toBe("<div><span>hey</span></div>"); expect(normalize(fixture.innerHTML)).toBe("<div><span>hey</span></div>");
}); });
test("t-foreach with t-component, and update", async () => {
class Child extends Widget {
static template = xml`
<span>
<t t-esc="state.val"/>
<t t-esc="props.val"/>
</span>`;
state = useState({ val: "A" });
mounted() {
this.state.val = "B";
}
}
class ParentWidget extends Widget {
static components = { Child };
static template = xml`
<div>
<t t-foreach="Array(2)" t-as="n" t-key="n_index">
<Child val="n_index"/>
</t>
</div>`;
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>A0</span><span>A1</span></div>");
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("<div><span>B0</span><span>B1</span></div>");
});
}); });
describe("random stuff/miscellaneous", () => { describe("random stuff/miscellaneous", () => {
@@ -2294,10 +2451,6 @@ describe("random stuff/miscellaneous", () => {
steps.push(`${this.name}:__patch`); steps.push(`${this.name}:__patch`);
super.__patch(vnode); super.__patch(vnode);
} }
__mount(vnode, elm) {
steps.push(`${this.name}:__patch(from __mount)`);
return super.__mount(vnode, elm);
}
mounted() { mounted() {
steps.push(`${this.name}:mounted`); steps.push(`${this.name}:mounted`);
} }
@@ -2394,15 +2547,15 @@ describe("random stuff/miscellaneous", () => {
"E:willStart", "E:willStart",
"D:render", "D:render",
"E:render", "E:render",
"E:__patch",
"D:__patch",
"C:__patch",
"B:__patch",
"A:__patch", "A:__patch",
"B:__patch(from __mount)",
"C:__patch(from __mount)",
"D:__patch(from __mount)",
"E:__patch(from __mount)",
"B:mounted",
"D:mounted",
"E:mounted", "E:mounted",
"D:mounted",
"C:mounted", "C:mounted",
"B:mounted",
"A:mounted" "A:mounted"
]); ]);
@@ -2419,12 +2572,12 @@ describe("random stuff/miscellaneous", () => {
"F:render", "F:render",
"C:willPatch", "C:willPatch",
"D:willPatch", "D:willPatch",
"F:__patch",
"D:__patch",
"C:__patch", "C:__patch",
"E:willUnmount", "E:willUnmount",
"E:destroy", "E:destroy",
"F:__patch(from __mount)",
"F:mounted", "F:mounted",
"D:__patch",
"D:patched", "D:patched",
"C:patched" "C:patched"
]); ]);
@@ -3527,6 +3680,43 @@ describe("async rendering", () => {
expect(Parent.prototype.__render).toHaveBeenCalledTimes(3); expect(Parent.prototype.__render).toHaveBeenCalledTimes(3);
}); });
test("concurrent renderings scenario 13", async () => {
let lastChild;
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="state.val"/></span>`;
state = useState({ val: 0 });
mounted() {
if (lastChild) {
lastChild.state.val = 0;
}
lastChild = this;
this.state.val = 1;
}
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<Child/>
<Child t-if="state.bool"/>
</div>`;
static components = { Child };
state = useState({ bool: false });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>0</span></div>");
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
parent.state.bool = true;
await nextTick(); // wait for this change to be applied
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("<div><span>0</span><span>1</span></div>");
});
test("change state and call manually render: no unnecessary rendering", async () => { test("change state and call manually render: no unnecessary rendering", async () => {
class Widget extends Component<any, any> { class Widget extends Component<any, any> {
static template = xml`<div><t t-esc="state.val"/></div>`; static template = xml`<div><t t-esc="state.val"/></div>`;
@@ -3719,7 +3909,12 @@ describe("t-slot directive", () => {
class Link extends Widget {} class Link extends Widget {}
class App extends Widget { class App extends Widget {
state = useState({ users: [{ id: 1, name: "Aaron" }, { id: 2, name: "David" }] }); state = useState({
users: [
{ id: 1, name: "Aaron" },
{ id: 2, name: "David" }
]
});
static components = { Link }; static components = { Link };
} }
@@ -3758,7 +3953,12 @@ describe("t-slot directive", () => {
class Link extends Widget {} class Link extends Widget {}
class App extends Widget { class App extends Widget {
state = useState({ users: [{ id: 1, name: "Aaron" }, { id: 2, name: "David" }] }); state = useState({
users: [
{ id: 1, name: "Aaron" },
{ id: 2, name: "David" }
]
});
static components = { Link }; static components = { Link };
} }
@@ -4017,6 +4217,37 @@ describe("t-slot directive", () => {
expect(childrenChildren[0]).toBeInstanceOf(GrandChild); expect(childrenChildren[0]).toBeInstanceOf(GrandChild);
}); });
test("nested slots: evaluation context and parented relationship", async () => {
let slot;
class Slot extends Component<any, any> {
static template = xml`<span t-esc="props.val"/>`;
constructor(parent, props) {
super(parent, props);
slot = this;
}
}
class GrandChild extends Component<any, any> {
static template = xml`<div><t t-slot="default"/></div>`;
}
class Child extends Component<any, any> {
static components = { GrandChild };
static template = xml`
<GrandChild>
<t t-slot="default"/>
</GrandChild>`;
}
class Parent extends Component<any, any> {
static components = { Child, Slot };
static template = xml`<Child><Slot val="state.val"/></Child>`;
state = useState({ val: 3 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>3</span></div>");
expect(slot.__owl__.parent).toBeInstanceOf(GrandChild);
});
test("slot are properly rendered if inner props are changed", async () => { test("slot are properly rendered if inner props are changed", async () => {
env.qweb.addTemplates(` env.qweb.addTemplates(`
<templates> <templates>
@@ -4101,6 +4332,84 @@ describe("t-slot directive", () => {
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><span>5</span></div>"); expect(fixture.innerHTML).toBe("<div><span>5</span></div>");
}); });
test("multiple slots containing components", async () => {
class C extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`;
}
class B extends Component<any, any> {
static template = xml`<div><t t-slot="s1"/><t t-slot="s2"/></div>`;
}
class A extends Component<any, any> {
static template = xml`
<B>
<t t-set="s1"><C val="1"/></t>
<t t-set="s2"><C val="2"/></t>
</B>`;
static components = { B, C };
}
const a = new A();
await a.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><span>1</span><span>2</span></div>`);
});
test("slots in t-foreach and re-rendering", async () => {
class Child extends Widget {
static template = xml`<span><t t-esc="state.val"/><t t-slot="default"/></span>`;
state = useState({ val: "A" });
mounted() {
this.state.val = "B";
}
}
class Parent extends Widget {
static components = { Child };
static template = xml`
<div>
<t t-foreach="Array(2)" t-as="n" t-key="n_index">
<Child><t t-esc="n_index"/></Child>
</t>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>A0</span><span>A1</span></div>");
await nextTick(); // wait for the changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("<div><span>B0</span><span>B1</span></div>");
});
test("slots in t-foreach with t-set and re-rendering", async () => {
class Child extends Widget {
static template = xml`
<span>
<t t-esc="state.val"/>
<t t-slot="default"/>
</span>`;
state = useState({ val: "A" });
mounted() {
this.state.val = "B";
}
}
class ParentWidget extends Widget {
static components = { Child };
static template = xml`
<div>
<t t-foreach="Array(2)" t-as="n" t-key="n_index">
<t t-set="dummy" t-value="n_index"/>
<Child><t t-esc="dummy"/></Child>
</t>
</div>`;
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>A0</span><span>A1</span></div>");
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("<div><span>B0</span><span>B1</span></div>");
});
}); });
describe("t-model directive", () => { describe("t-model directive", () => {
@@ -4394,7 +4703,11 @@ describe("t-model directive", () => {
</t> </t>
</div> </div>
`; `;
state = useState([{ f: false, id: 1 }, { f: false, id: 2 }, { f: false, id: 3 }]); state = useState([
{ f: false, id: 1 },
{ f: false, id: 2 },
{ f: false, id: 3 }
]);
} }
const comp = new SomeComponent(); const comp = new SomeComponent();
await comp.mount(fixture); await comp.mount(fixture);
@@ -4557,7 +4870,7 @@ describe("component error handling (catchError)", () => {
expect(handler).toBeCalledTimes(1); expect(handler).toBeCalledTimes(1);
}); });
test("can catch an error in the initial call of a component render function", async () => { test("can catch an error in the initial call of a component render function (parent mounted)", async () => {
const handler = jest.fn(); const handler = jest.fn();
env.qweb.on("error", null, handler); env.qweb.on("error", null, handler);
const consoleError = console.error; const consoleError = console.error;
@@ -4593,6 +4906,45 @@ describe("component error handling (catchError)", () => {
expect(handler).toBeCalledTimes(1); expect(handler).toBeCalledTimes(1);
}); });
test("can catch an error in the initial call of a component render function (parent updated)", async () => {
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component<any, any> {
static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`;
}
class ErrorBoundary extends Component<any, any> {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else="1"><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component<any, any> {
static template = xml`
<div>
<ErrorBoundary t-if="state.flag"><ErrorComponent /></ErrorBoundary>
</div>`;
state = useState({ flag: false });
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
app.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the constructor call of a component render function", async () => { test("can catch an error in the constructor call of a component render function", async () => {
const handler = jest.fn(); const handler = jest.fn();
env.qweb.on("error", null, handler); env.qweb.on("error", null, handler);
@@ -5263,6 +5615,73 @@ describe("unmounting and remounting", () => {
expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2); expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2);
expect(steps).toEqual([2, 2, 3]); expect(steps).toEqual([2, 2, 3]);
}); });
test("change state while component is unmounted", async () => {
let child;
class Child extends Component<any, any> {
static template = xml`<span t-esc="state.val"/>`;
state = useState({
val: "C1"
});
constructor(parent, props) {
super(parent, props);
child = this;
}
}
class Parent extends Component<any, any> {
static components = { Child };
static template = xml`<div><t t-esc="state.val"/><Child/></div>`;
state = useState({ val: "P1" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div>P1<span>C1</span></div>");
parent.unmount();
expect(fixture.innerHTML).toBe("");
parent.state.val = "P2";
child.state.val = "C2";
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div>P2<span>C2</span></div>");
});
test("unmount component during a re-rendering", async () => {
const def = makeDeferred();
class Child extends Widget {
static template = xml`<span><t t-esc="props.val"/></span>`;
willUpdateProps() {
return def;
}
}
Child.prototype.__render = jest.fn(Child.prototype.__render);
class Parent extends Widget {
static template = xml`<div><Child val="state.val"/></div>`;
static components = { Child };
state = useState({ val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
expect(Child.prototype.__render).toBeCalledTimes(1);
parent.state.val = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
parent.unmount();
expect(fixture.innerHTML).toBe("");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("");
expect(Child.prototype.__render).toBeCalledTimes(1);
});
}); });
describe("dynamic root nodes", () => { describe("dynamic root nodes", () => {
+74 -10
View File
@@ -138,7 +138,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component '_a'`); expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
} }
}); });
@@ -195,7 +195,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component '_a'`); expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
} }
}); });
@@ -240,7 +240,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe("Props 'p' of invalid type in component 'TestWidget'"); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
}); });
test("can validate an optional props", async () => { test("can validate an optional props", async () => {
@@ -284,7 +284,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component 'TestWidget'`); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
}); });
test("can validate an array with given primitive type", async () => { test("can validate an array with given primitive type", async () => {
@@ -389,7 +389,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component 'TestWidget'`); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
}); });
test("can validate an object with simple shape", async () => { test("can validate an object with simple shape", async () => {
@@ -436,7 +436,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component 'TestWidget'`); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
error = undefined; error = undefined;
try { try {
@@ -447,7 +447,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component 'TestWidget'`); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
}); });
test("can validate recursively complicated prop def", async () => { test("can validate recursively complicated prop def", async () => {
@@ -499,7 +499,7 @@ describe("props validation", () => {
error = e; error = e;
} }
expect(error).toBeDefined(); expect(error).toBeDefined();
expect(error.message).toBe(`Props 'p' of invalid type in component 'TestWidget'`); expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
}); });
test("can validate optional attributes in nested sub props", () => { test("can validate optional attributes in nested sub props", () => {
@@ -535,6 +535,70 @@ describe("props validation", () => {
); );
}); });
test("can validate with a custom validator", () => {
class TestComponent extends Component<any, any> {
static props = {
size: {
validate: e => ["small", "medium", "large"].includes(e)
}
};
}
let error;
try {
QWeb.utils.validateProps(TestComponent, { size: "small" });
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
try {
QWeb.utils.validateProps(TestComponent, { size: "abcdef" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'size' in component 'TestComponent'");
});
test("can validate with a custom validator, and a type", () => {
const validator = jest.fn(n => 0 <= n && n <= 10);
class TestComponent extends Component<any, any> {
static props = {
n: {
type: Number,
validate: validator
}
};
}
let error;
try {
QWeb.utils.validateProps(TestComponent, { n: 3 });
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
expect(validator).toBeCalledTimes(1);
try {
QWeb.utils.validateProps(TestComponent, { n: "str" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
expect(validator).toBeCalledTimes(1);
error = null;
try {
QWeb.utils.validateProps(TestComponent, { n: 100 });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
expect(validator).toBeCalledTimes(2);
});
test("props are validated in dev mode (code snapshot)", async () => { test("props are validated in dev mode (code snapshot)", async () => {
env.qweb.addTemplates(` env.qweb.addTemplates(`
<templates> <templates>
@@ -591,7 +655,7 @@ describe("props validation", () => {
}).not.toThrow(); }).not.toThrow();
expect(() => { expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: 1 }); QWeb.utils.validateProps(TestWidget, { myprop: 1 });
}).toThrow(`Props 'myprop' of invalid type in component 'TestWidget'`); }).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
}); });
test("props with type object, and no shape", async () => { test("props with type object, and no shape", async () => {
@@ -604,7 +668,7 @@ describe("props validation", () => {
}).not.toThrow(); }).not.toThrow();
expect(() => { expect(() => {
QWeb.utils.validateProps(TestWidget, { myprop: false }); QWeb.utils.validateProps(TestWidget, { myprop: false });
}).toThrow(`Props 'myprop' of invalid type in component 'TestWidget'`); }).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
}); });
test("props: extra props cause an error", async () => { test("props: extra props cause an error", async () => {
+2
View File
@@ -1,6 +1,7 @@
import { Env } from "../src/component/component"; import { Env } from "../src/component/component";
import { scheduler } from "../src/component/scheduler"; import { scheduler } from "../src/component/scheduler";
import { EvalContext, QWeb } from "../src/qweb/qweb"; import { EvalContext, QWeb } from "../src/qweb/qweb";
import { CompilationContext } from "../src/qweb/compilation_context";
import { patch } from "../src/vdom"; import { patch } from "../src/vdom";
import "../src/qweb/base_directives"; import "../src/qweb/base_directives";
import "../src/qweb/extensions"; import "../src/qweb/extensions";
@@ -20,6 +21,7 @@ let TEMPLATES;
beforeEach(() => { beforeEach(() => {
nextSlotId = QWeb.nextSlotId; nextSlotId = QWeb.nextSlotId;
CompilationContext.nextID = 1;
slots = Object.assign({}, QWeb.slots); slots = Object.assign({}, QWeb.slots);
nextId = QWeb.nextId; nextId = QWeb.nextId;
TEMPLATES = Object.assign({}, QWeb.TEMPLATES); TEMPLATES = Object.assign({}, QWeb.TEMPLATES);
+828
View File
@@ -0,0 +1,828 @@
import { Portal } from "../../src/misc/portal";
import { xml } from "../../src/tags";
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
import { Component } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { QWeb } from "../../src/qweb";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - outside: a div with id #outside appended into fixture, meant to be used as
// target by Portal component
// - a test env, necessary to create components, that is set on Component
let fixture: HTMLElement;
let outside: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
outside = document.createElement("div");
outside.setAttribute("id", "outside");
fixture.appendChild(outside);
Component.env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
describe("Portal: Props validation", () => {
test("target is mandatory", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal>
<div>2</div>
</Portal>
</div>`;
}
let error;
try {
const parent = new Parent();
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'target' (component 'Portal')`);
QWeb.dev = dev;
});
test("target is not list", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="['body']">
<div>2</div>
</Portal>
</div>`;
}
let error;
try {
const parent = new Parent();
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Invalid Prop 'target' in component 'Portal'`);
QWeb.dev = dev;
});
});
describe("Portal: Basic use and DOM placement", () => {
test("basic use of portal", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#outside'">
<div>2</div>
</Portal>
</div>`;
}
let error;
let parent;
try {
parent = new Parent();
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
QWeb.dev = dev;
});
test("conditional use of Portal", async () => {
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#outside'" t-if="state.hasPortal">
<div>2</div>
</Portal>
</div>`;
state = useState({ hasPortal: false });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
parent.state.hasPortal = false;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
});
test("conditional use of Portal (with sub Component)", async () => {
class Child extends Component<any, any> {
static template = xml`<div><t t-esc="props.val"/></div>`;
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<span>1</span>
<Portal t-if="state.hasPortal" target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ hasPortal: false, val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>1</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
parent.state.hasPortal = false;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.val = 2;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
});
test("with target in template (before portal)", async () => {
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<div id="local-target"></div>
<span>1</span>
<Portal target="'#local-target'">
<p>2</p>
</Portal>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'<div id="local-target"><p>2</p></div><span>1</span><portal></portal>'
);
});
test("with target in template (after portal)", async () => {
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#local-target'">
<p>2</p>
</Portal>
<div id="local-target"></div>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'<span>1</span><portal></portal><div id="local-target"><p>2</p></div>'
);
});
test("portal with target not in dom", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#does-not-exist'">
<div>2</div>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe('Could not find any match for "#does-not-exist"');
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with child and props", async () => {
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`;
mounted() {
steps.push("mounted");
expect(outside.innerHTML).toBe("<span>1</span>");
}
patched() {
steps.push("patched");
expect(outside.innerHTML).toBe("<span>2</span>");
}
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>1</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
parent.state.val = 2;
await nextTick();
expect(outside.innerHTML).toBe("<span>2</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
expect(steps).toEqual(["mounted", "patched"]);
});
test("portal with only text as content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<t t-esc="'only text'"/>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with no content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<t t-if="false" t-esc="'ABC'"/>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with many children", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<div>1</div>
<p>2</p>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 2)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with dynamic body", async () => {
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span t-if="state.val" t-esc="state.val"/>
<div t-else=""/>
</Portal>
</div>`;
state = useState({ val: "ab" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
parent.state.val = "";
await nextTick();
expect(outside.innerHTML).toBe(`<div></div>`);
});
test("portal could have dynamically no content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span t-if="state.val" t-esc="state.val"/>
</Portal>
</div>`;
state = { val: "ab" };
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
let error;
try {
parent.state.val = "";
await parent.render();
} catch (e) {
error = e;
}
expect(outside.innerHTML).toBe(``);
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("lifecycle hooks of portal sub component are properly called", async () => {
const steps: any[] = [];
class Child extends Component<any, any> {
static template = xml`<span t-esc="props.val"/>`;
mounted() {
steps.push("child:mounted");
}
willPatch() {
steps.push("child:willPatch");
}
patched() {
steps.push("child:patched");
}
willUnmount() {
steps.push("child:willUnmount");
}
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<Portal t-if="state.hasChild" target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ hasChild: false, val: 1 });
mounted() {
steps.push("parent:mounted");
}
willPatch() {
steps.push("parent:willPatch");
}
patched() {
steps.push("parent:patched");
}
willUnmount() {
steps.push("parent:willUnmount");
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(steps).toEqual(["parent:mounted"]);
parent.state.hasChild = true;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched"
]);
parent.state.val = 2;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched"
]);
parent.state.hasChild = false;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched",
"parent:willPatch",
"child:willUnmount",
"parent:patched"
]);
});
test("portal destroys on crash", async () => {
class Child extends Component<any, any> {
static template = xml`<span t-esc="props.error and this.will.crash" />`;
state = {};
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'" >
<Child error="state.error"/>
</Portal>
</div>`;
state = { error: false };
}
const parent = new Parent();
await parent.mount(fixture);
parent.state.error = true;
let error;
try {
await parent.render();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Cannot read property 'crash' of undefined");
});
});
describe("Portal: Events handling", () => {
test("events triggered on movable pure node are handled", async () => {
class Parent extends Component<any, any> {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span id="trigger-me" t-on-custom="_onCustom" t-esc="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
_onCustom() {
this.state.val = "triggered";
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span id="trigger-me">ab</span>`);
outside.querySelector("#trigger-me")!.dispatchEvent(new Event("custom"));
await nextTick();
expect(outside.innerHTML).toBe(`<span id="trigger-me">triggered</span>`);
});
test("events triggered on movable owl components are redirected", async () => {
let childInst: Component<any, any> | null = null;
class Child extends Component<any, any> {
static template = xml`
<span t-on-custom="_onCustom" t-esc="props.val"/>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
_onCustom() {
this.trigger("custom-portal");
}
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div t-on-custom-portal="_onCustomPortal">
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
_onCustomPortal() {
this.state.val = "triggered";
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
childInst!.trigger("custom");
await nextTick();
expect(outside.innerHTML).toBe(`<span>triggered</span>`);
});
test("events triggered on contained movable owl components are redirected", async () => {
const steps: string[] = [];
let childInst: Component<any, any> | null = null;
class Child extends Component<any, any> {
static template = xml`
<span t-on-custom="_onCustom"/>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
_onCustom() {
this.trigger("custom-portal");
}
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div t-on-custom="_handled" t-on-custom-portal="_handled">
<Portal target="'#outside'">
<div>
<Child/>
</div>
</Portal>
</div>`;
_handled(ev) {
steps.push(ev.type);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
await nextTick();
// This is expected because trigger is synchronous
expect(steps).toMatchObject(["custom-portal", "custom"]);
});
test("Dom events are not mapped", async () => {
let childInst: Component<any, any> | null = null;
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`
<button>child</button>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div t-on-click="_handled">
<Portal target="'#outside'">
<Child />
</Portal>
</div>`;
_handled(ev) {
steps.push(ev.type as string);
}
}
const bodyListener = ev => {
steps.push(`body: ${ev.type}`);
};
document.body.addEventListener("click", bodyListener);
const parent = new Parent();
await parent.mount(fixture);
childInst!.el!.click();
expect(steps).toEqual(["body: click"]);
document.body.removeEventListener("click", bodyListener);
});
test("Nested portals event propagation", async () => {
const outside2 = document.createElement("div");
outside2.setAttribute("id", "outside2");
fixture.appendChild(outside2);
const steps: Array<string> = [];
let childInst: Component<any, any> | null = null;
class Child2 extends Component<any, any> {
static template = xml`<div>child2</div>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Child extends Component<any, any> {
static components = { Portal, Child2 };
static template = xml`
<Portal target="'#outside2'">
<Child2 />
</Portal>`;
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div t-on-custom='_handled'>
<Portal target="'#outside'">
<Child/>
</Portal>
</div>`;
_handled(ev) {
steps.push(`${ev.type} from ${ev.originalComponent.constructor.name}`);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
expect(steps).toEqual(["custom from Child2"]);
});
test("portal's parent's env is not polluted", async () => {
class Child extends Component<any, any> {
static template = xml`
<button>child</button>`;
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child />
</Portal>
</div>`;
}
const parent = new Parent();
const parentEnv = Object.assign({}, parent.env);
await parent.mount(fixture);
expect(parentEnv).toStrictEqual(parent.env);
});
test("Portal composed with t-slot", async () => {
const steps: Array<string> = [];
let childInst: Component<any, any> | null = null;
class Child2 extends Component<any, any> {
static template = xml`<div>child2</div>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Child extends Component<any, any> {
static components = { Portal, Child2 };
static template = xml`
<Portal target="'#outside'">
<t t-slot="default"/>
</Portal>`;
}
class Parent extends Component<any, any> {
static components = { Child, Child2 };
static template = xml`
<div t-on-custom='_handled'>
<Child>
<Child2/>
</Child>
</div>`;
_handled(ev) {
steps.push(ev.type as string);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
expect(steps).toEqual(["custom"]);
});
});
describe("Portal: UI/UX", () => {
test("focus is kept across re-renders", async () => {
class Child extends Component<any, any> {
static template = xml`
<input id="target-me" t-att-placeholder="props.val"/>`;
}
class Parent extends Component<any, any> {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
}
const parent = new Parent();
await parent.mount(fixture);
const input = document.querySelector("#target-me");
expect(input!.nodeName).toBe("INPUT");
expect((input as HTMLInputElement).placeholder).toBe("ab");
(input as HTMLInputElement).focus();
expect(document.activeElement === input).toBeTruthy();
parent.state.val = "bc";
await nextTick();
const inputReRendered = document.querySelector("#target-me");
expect(inputReRendered!.nodeName).toBe("INPUT");
expect((inputReRendered as HTMLInputElement).placeholder).toBe("bc");
expect(document.activeElement === inputReRendered).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -673,6 +673,40 @@ describe("t-call (template calling", () => {
expect(renderToString(qweb, "main")).toBe(expected); expect(renderToString(qweb, "main")).toBe(expected);
}); });
test("cascading t-call t-raw='0'", () => {
qweb.addTemplates(`
<templates>
<div t-name="finalTemplate">
<span>cascade 2</span>
<t t-raw="0"/>
</div>
<div t-name="subSubTemplate">
<t t-call="finalTemplate">
<span>cascade 1</span>
<t t-raw="0"/>
</t>
</div>
<div t-name="SubTemplate">
<t t-call="subSubTemplate">
<span>cascade 0</span>
<t t-raw="0"/>
</t>
</div>
<div t-name="main">
<t t-call="SubTemplate">
<span>hey</span> <span>yay</span>
</t>
</div>
</templates>
`);
const expected =
"<div><div><div><div><span>cascade 2</span><span>cascade 1</span><span>cascade 0</span><span>hey</span> <span>yay</span></div></div></div></div>";
expect(renderToString(qweb, "main")).toBe(expected);
});
test("recursive template, part 1", () => { test("recursive template, part 1", () => {
qweb.addTemplates(` qweb.addTemplates(`
<templates> <templates>
@@ -1225,7 +1259,10 @@ describe("t-on", () => {
); );
const steps: string[] = []; const steps: string[] = [];
const owner = { const owner = {
projects: [{ id: 1, name: "Project 1" }, { id: 2, name: "Project 2" }], projects: [
{ id: 1, name: "Project 1" },
{ id: 2, name: "Project 2" }
],
onEdit(projectId, ev) { onEdit(projectId, ev) {
expect(ev.defaultPrevented).toBe(true); expect(ev.defaultPrevented).toBe(true);
+4 -1
View File
@@ -27,7 +27,10 @@ describe("tokenizer", () => {
{ type: "VALUE", value: "2" }, { type: "VALUE", value: "2" },
{ type: "RIGHT_BRACE", value: "}" } { type: "RIGHT_BRACE", value: "}" }
]); ]);
expect(tokenize("a,")).toEqual([{ type: "SYMBOL", value: "a" }, { type: "COMMA", value: "," }]); expect(tokenize("a,")).toEqual([
{ type: "SYMBOL", value: "a" },
{ type: "COMMA", value: "," }
]);
expect(tokenize("][")).toEqual([ expect(tokenize("][")).toEqual([
{ type: "RIGHT_BRACKET", value: "]" }, { type: "RIGHT_BRACKET", value: "]" },
{ type: "LEFT_BRACKET", value: "[" } { type: "LEFT_BRACKET", value: "[" }
+10 -11
View File
@@ -5,18 +5,17 @@ exports[`Link component can render simple cases 1`] = `
) { ) {
let utils = this.constructor.utils; let utils = this.constructor.utils;
let owner = context; let owner = context;
let sibling = null;
var h = this.h; var h = this.h;
let _1 = utils.toObj({'router-link-active':context['isActive']}); let _5 = utils.toObj({'router-link-active':context['isActive']});
var _2 = context['href']; var _6 = context['href'];
let c3 = [], p3 = {key:3,attrs:{href: _2},class:_1,on:{}}; let c7 = [], p7 = {key:7,attrs:{href: _6},class:_5,on:{}};
var vn3 = h('a', p3, c3); var vn7 = h('a', p7, c7);
extra.handlers['click' + 3] = extra.handlers['click' + 3] || function (e) {const fn = context['navigate'];if (fn) { fn.call(owner, e); } else { context.navigate; }}; extra.handlers['click' + 7] = extra.handlers['click' + 7] || function (e) {const fn = context['navigate'];if (fn) { fn.call(owner, e); } else { context.navigate; }};
p3.on['click'] = extra.handlers['click' + 3]; p7.on['click'] = extra.handlers['click' + 7];
const slot4 = this.constructor.slots[context.__owl__.slotId + '_' + 'default']; const slot8 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot4) { if (slot8) {
slot4.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: c3, vars: extra.vars, parent: owner})); slot8.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: c7, vars: extra.vars, parent: extra.parent || owner}));
} }
return vn3; return vn7;
}" }"
`; `;
@@ -7,40 +7,36 @@ exports[`RouteComponent can render simple cases 1`] = `
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let owner = context; let owner = context;
let sibling = null;
let result; let result;
var h = this.h; var h = this.h;
if (context['routeComponent']) { if (context['routeComponent']) {
const nodeKey1 = context['env'].router.currentRouteName; const nodeKey5 = context['env'].router.currentRouteName;
//COMPONENT //COMPONENT
let k4 = \`__5__\` + nodeKey1; let k7 = \`__8__\` + nodeKey5;
let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false; let w6 = k7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k7]] : false;
let vn6 = {}; let vn9 = {};
result = vn6; result = vn9;
let props3 = Object.assign({}, context['env'].router.currentParams); let props6 = Object.assign({}, context['env'].router.currentParams);
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) { if (w6 && w6.__owl__.currentFiber && !w6.__owl__.vnode) {
w3.destroy(); w6.destroy();
w3 = false; w6 = false;
} }
if (w3) { if (w6) {
w3.__updateProps(props3, extra.fiber, undefined, undefined, sibling); w6.__updateProps(props6, extra.fiber, undefined, undefined);
let pvnode = w3.__owl__.pvnode; let pvnode = w6.__owl__.pvnode;
utils.defineProxy(vn6, pvnode); utils.defineProxy(vn9, pvnode);
} else { } else {
let componentKey3 = \`routeComponent\`; let componentKey6 = \`routeComponent\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['routeComponent']; let W6 = context.constructor.components[componentKey6] || QWeb.components[componentKey6]|| context['routeComponent'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')} if (!W6) {throw new Error('Cannot find the definition of component \\"' + componentKey6 + '\\"')}
w3 = new W3(parent, props3); w6 = new W6(parent, props6);
parent.__owl__.cmap[k4] = w3.__owl__.id; parent.__owl__.cmap[k7] = w6.__owl__.id;
let def2 = w3.__prepare(extra.fiber, undefined, undefined, sibling); let fiber = w6.__prepare(extra.fiber, undefined, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k4, hook: {insert(vn) { let nvn=w3.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w3.destroy();}}}); let pvnode = h('dummy', {key: k7, hook: {remove() {},destroy(vn) {w6.destroy();}}});
const fiber = w3.__owl__.currentFiber; utils.defineProxy(vn9, pvnode);
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; }); w6.__owl__.pvnode = pvnode;
utils.defineProxy(vn6, pvnode);
w3.__owl__.pvnode = pvnode;
} }
w3.__owl__.parentLastFiberId = extra.fiber.id; w6.__owl__.parentLastFiberId = extra.fiber.id;
sibling = w3.__owl__.currentFiber || sibling;
} }
return result; return result;
}" }"
+8 -2
View File
@@ -35,7 +35,10 @@ describe("Link component", () => {
static components = { Link: Link }; static components = { Link: Link };
} }
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }]; const routes = [
{ name: "about", path: "/about" },
{ name: "users", path: "/users" }
];
router = new TestRouter(env, routes, { mode: "history" }); router = new TestRouter(env, routes, { mode: "history" });
router.navigate({ to: "users" }); router.navigate({ to: "users" });
@@ -66,7 +69,10 @@ describe("Link component", () => {
static components = { Link: Link }; static components = { Link: Link };
} }
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }]; const routes = [
{ name: "about", path: "/about" },
{ name: "users", path: "/users" }
];
router = new TestRouter(env, routes, { mode: "history" }); router = new TestRouter(env, routes, { mode: "history" });
router.navigate({ to: "users" }); router.navigate({ to: "users" });
+126 -2
View File
@@ -48,6 +48,42 @@ describe("connecting a component to store", () => {
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>"); expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
}); });
test("useStore can observe primitive types and call onUpdate", async () => {
const state = { isBoolean: false };
const actions = {
setTrue({ state }) {
state.isBoolean = true;
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`
<div>
<span t-if="isBoolean">ok</span>
</div>`;
isBoolean: boolean;
constructor() {
super();
this.isBoolean = useStore(state => state.isBoolean, {
onUpdate: isBoolean => {
this.isBoolean = isBoolean;
}
});
}
}
(<any>env).store = store;
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
store.dispatch("setTrue");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>ok</span></div>");
});
test("throw error if no store is found", async () => { test("throw error if no store is found", async () => {
class App extends Component<any, any> { class App extends Component<any, any> {
static template = xml`<div></div>`; static template = xml`<div></div>`;
@@ -64,6 +100,24 @@ describe("connecting a component to store", () => {
expect(error.message).toBe("No store found when connecting 'App'"); expect(error.message).toBe("No store found when connecting 'App'");
}); });
test("cannot modify state returned by usestore", async () => {
const state = { a: { b: 1 } };
const actions = {};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`<div/>`;
storeState = useStore(state => state.a);
}
(<any>env).store = store;
const app = new App();
expect(app.storeState.b).toBe(1);
expect(() => (app.storeState.b = 2)).toThrow(
"Store state should only be modified through actions"
);
});
test("can use useStore twice in a component", async () => { test("can use useStore twice in a component", async () => {
const state = { a: 1, b: 2 }; const state = { a: 1, b: 2 };
const actions = { const actions = {
@@ -257,7 +311,12 @@ describe("connecting a component to store", () => {
}); });
test("useStore can use props", async () => { test("useStore can use props", async () => {
const state = { todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "chimay" }] }; const state = {
todos: [
{ id: 1, text: "jupiler" },
{ id: 2, text: "chimay" }
]
};
const store = new Store({ state, actions: {} }); const store = new Store({ state, actions: {} });
class TodoItem extends Component<any, any> { class TodoItem extends Component<any, any> {
@@ -324,7 +383,10 @@ describe("connecting a component to store", () => {
test("can call useGetters to receive store getters", async () => { test("can call useGetters to receive store getters", async () => {
const state = { const state = {
importantID: 1, importantID: 1,
todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "bertinchamps" }] todos: [
{ id: 1, text: "jupiler" },
{ id: 2, text: "bertinchamps" }
]
}; };
const getters = { const getters = {
importantTodoText({ state }) { importantTodoText({ state }) {
@@ -428,6 +490,68 @@ describe("connecting a component to store", () => {
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>kwak</span></div>"); expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>kwak</span></div>");
}); });
test("connected component is updated when mixing store and props changes", async () => {
let counter = 0;
class Beer extends Component<any, any> {
static template = xml`<span><t t-esc="beer.name"/></span>`;
beer = useStore((state, props) => state.beers[props.id], {
onUpdate: result => {
++counter;
}
});
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
static components = { Beer };
state = useState({ beerId: 1 });
}
const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } };
const actions = {
renameBeer({ state }, { id, name }) {
state.beers[id].name = name;
}
};
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
expect(counter).toBe(0);
app.state.beerId = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>kwak</span></div>");
expect(counter).toBe(1);
store.dispatch("renameBeer", { id: 2, name: "orval" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>orval</span></div>");
expect(counter).toBe(2);
});
test("connected component is properly cleaned up on destroy", async () => {
class App extends Component<any, any> {
static template = xml`<div></div>`;
state = useStore((state, props) => state);
}
const store = new Store({ state: {} });
(<any>env).store = store;
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
expect(store.updateFunctions[app.__owl__.id].length).toBe(1);
app.destroy();
expect(store.updateFunctions[app.__owl__.id]).toBe(undefined);
});
test("connected component with undefined, null and string props", async () => { test("connected component with undefined, null and string props", async () => {
class Beer extends Component<any, any> { class Beer extends Component<any, any> {
static template = xml` static template = xml`
+57
View File
@@ -0,0 +1,57 @@
/**
* We can only make one test per file, since the debug tool modify in place
* the owl object in a way that is difficult to undo.
*/
import { debugOwl } from "../../tools/debug";
import * as owl from "../../src/index";
import { Component, Env } from "../../src/component/component";
import { xml } from "../../src/tags";
import { useState } from "../../src/hooks";
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
let fixture: HTMLElement = makeTestFixture();
let env: Env = makeTestEnv();
Component.env = env;
debugOwl(owl, {});
test("can log full lifecycle", async () => {
const steps: string[] = [];
const log = console.log;
console.log = arg => steps.push(arg);
class Child extends Component<any, any> {
static template = xml`<div>child</div>`;
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: false });
}
const parent = new Parent(null, {});
await parent.mount(fixture);
parent.state.flag = true;
await nextTick();
expect(steps).toEqual([
"[OWL_DEBUG] Parent<id=1> constructor, props={}",
"[OWL_DEBUG] Parent<id=1> mount",
"[OWL_DEBUG] Parent<id=1> willStart",
"[OWL_DEBUG] Parent<id=1> rendering template",
"[OWL_DEBUG] Parent<id=1> mounted",
"[OWL_DEBUG] Parent<id=1> render",
"[OWL_DEBUG] Parent<id=1> rendering template",
"[OWL_DEBUG] Child<id=2> constructor, props={}",
"[OWL_DEBUG] Child<id=2> willStart",
"[OWL_DEBUG] Child<id=2> rendering template",
"[OWL_DEBUG] Parent<id=1> willPatch",
"[OWL_DEBUG] Child<id=2> mounted",
"[OWL_DEBUG] Parent<id=1> patched"
]);
console.log = log;
});
+50
View File
@@ -0,0 +1,50 @@
/**
* We can only make one test per file, since the debug tool modify in place
* the owl object in a way that is difficult to undo.
*/
import { debugOwl } from "../../tools/debug";
import * as owl from "../../src/index";
import { Component, Env } from "../../src/component/component";
import { xml } from "../../src/tags";
import { makeTestFixture, makeTestEnv } from "../helpers";
let fixture: HTMLElement = makeTestFixture();
let env: Env = makeTestEnv();
Component.env = env;
debugOwl(owl, { logScheduler: true });
test("can log scheduler start and stop", async () => {
const steps: string[] = [];
const log = console.log;
console.log = arg => steps.push(arg);
class Child extends Component<any, any> {
static template = xml`<div>child</div>`;
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /></div>`;
static components = { Child };
}
const parent = new Parent(null, {});
await parent.mount(fixture);
expect(steps).toEqual([
"[OWL_DEBUG] Parent<id=1> constructor, props={}",
"[OWL_DEBUG] Parent<id=1> mount",
"[OWL_DEBUG] Parent<id=1> willStart",
"[OWL_DEBUG] scheduler: start running tasks queue",
"[OWL_DEBUG] Parent<id=1> rendering template",
"[OWL_DEBUG] Child<id=2> constructor, props={}",
"[OWL_DEBUG] Child<id=2> willStart",
"[OWL_DEBUG] Child<id=2> rendering template",
"[OWL_DEBUG] Child<id=2> mounted",
"[OWL_DEBUG] Parent<id=1> mounted",
"[OWL_DEBUG] scheduler: stop running tasks queue"
]);
console.log = log;
});
@@ -85,7 +85,7 @@ function getFiles(path: string[] = []): FileData[] {
return Array.prototype.concat(...files); return Array.prototype.concat(...files);
} }
const LOCAL_FILES = ["LICENSE"]; const LOCAL_FILES = ["LICENSE", "tools/debug.js"];
export function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean { export function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean {
if (link.link.startsWith("http")) { if (link.link.startsWith("http")) {
// no check on external links // no check on external links
+139
View File
@@ -0,0 +1,139 @@
/**
* Debug Script
*
* This code is intended to be evaluated in an environment where owl is available,
* to log lot of helpful information on how Owl components behave.
*/
function debugOwl(owl, options) {
let prefix = "[OWL_DEBUG]";
let current;
Object.defineProperty(owl.Component, "current", {
get() {
return current;
},
set(comp) {
current = comp;
const name = comp.constructor.name;
if (options.componentBlackList && options.componentBlackList.test(name)) {
return;
}
if (options.componentWhiteList && !options.componentWhiteList.test(name)) {
return;
}
let __owl__;
Object.defineProperty(current, "__owl__", {
get() {
return __owl__;
},
set(val) {
__owl__ = val;
debugComponent(comp, name, __owl__.id);
}
});
}
});
function toStr(obj) {
let str = JSON.stringify(obj || {});
if (str.length > 200) {
str = str.slice(0, 200) + "...";
}
return str;
}
function debugComponent(component, name, id) {
let fullName = `${name}<id=${id}>`;
let shouldDebug = method => {
if (options.methodBlackList && options.methodBlackList.includes(method)) {
return false;
}
if (options.methodWhiteList && !options.methodWhiteList.includes(method)) {
return false;
}
return true;
};
if (shouldDebug("constructor")) {
console.log(`${prefix} ${fullName} constructor, props=${toStr(component.props)}`);
}
if (shouldDebug("willStart")) {
owl.hooks.onWillStart(() => {
console.log(`${prefix} ${fullName} willStart`);
});
}
if (shouldDebug("mounted")) {
owl.hooks.onMounted(() => {
console.log(`${prefix} ${fullName} mounted`);
});
}
if (shouldDebug("willUpdateProps")) {
owl.hooks.onWillUpdateProps(nextProps => {
console.log(`${prefix} ${fullName} willUpdateProps, nextprops=${toStr(nextProps)}`);
});
}
if (shouldDebug("willPatch")) {
owl.hooks.onWillPatch(() => {
console.log(`${prefix} ${fullName} willPatch`);
});
}
if (shouldDebug("patched")) {
owl.hooks.onPatched(() => {
console.log(`${prefix} ${fullName} patched`);
});
}
if (shouldDebug("willUnmount")) {
owl.hooks.onWillUnmount(() => {
console.log(`${prefix} ${fullName} willUnmount`);
});
}
const __render = component.__render.bind(component);
component.__render = function(...args) {
console.log(`${prefix} ${fullName} rendering template`);
__render(...args);
};
const render = component.render.bind(component);
component.render = function(...args) {
console.log(`${prefix} ${fullName} render`);
return render(...args);
};
const mount = component.mount.bind(component);
component.mount = function(...args) {
console.log(`${prefix} ${fullName} mount`);
return mount(...args);
};
}
if (options.logScheduler) {
let start = owl.Component.scheduler.start;
let stop = owl.Component.scheduler.stop;
owl.Component.scheduler.start = function () {
console.log(`${prefix} scheduler: start running tasks queue`);
start.call(this);
};
owl.Component.scheduler.stop = function () {
console.log(`${prefix} scheduler: stop running tasks queue`);
stop.call(this);
};
}
if (options.logStore) {
let dispatch = owl.Store.prototype.dispatch;
owl.Store.prototype.dispatch = function(action, ...payload) {
console.log(`${prefix} store: action '${action}' dispatched. Payload: '${toStr(payload)}'`);
return dispatch.call(this, action, ...payload);
};
}
}
// This debug function can then be used like this:
//
// debugOwl(owl, {
// componentBlackList: /App/, // regexp
// componentWhiteList: /SomeComponent/, // regexp
// methodBlackList: ["mounted"], // list of method names
// methodWhiteList: ["willStart"], // list of method names
// logScheduler: true, // display/mute scheduler logs
// logStore: true // display/mute store logs
// });
module.exports.debugOwl = debugOwl;
+200 -39
View File
@@ -2,7 +2,10 @@ const COMPONENTS = `// In this example, we show how components can be defined an
const { Component, useState } = owl; const { Component, useState } = owl;
class Greeter extends Component { class Greeter extends Component {
state = useState({ word: 'Hello' }); constructor() {
super(...arguments);
this.state = useState({ word: 'Hello' });
}
toggle() { toggle() {
this.state.word = this.state.word === 'Hi' ? 'Hello' : 'Hi'; this.state.word = this.state.word === 'Hi' ? 'Hello' : 'Hi';
@@ -11,7 +14,10 @@ class Greeter extends Component {
// Main root component // Main root component
class App extends Component { class App extends Component {
state = useState({ name: 'World'}); constructor() {
super(...arguments);
this.state = useState({ name: 'World'});
}
} }
App.components = { Greeter }; App.components = { Greeter };
@@ -47,7 +53,10 @@ const ANIMATION = `// The goal of this component is to see how the t-transition
const { Component, useState } = owl; const { Component, useState } = owl;
class Counter extends Component { class Counter extends Component {
state = useState({ value: 0 }); constructor() {
super(...arguments);
this.state = useState({ value: 0 });
}
increment() { increment() {
this.state.value++; this.state.value++;
@@ -55,7 +64,10 @@ class Counter extends Component {
} }
class App extends Component { class App extends Component {
state = useState({ flag: false, componentFlag: false, numbers: [] }); constructor() {
super(...arguments);
this.state = useState({ flag: false, componentFlag: false, numbers: [] });
}
toggle(key) { toggle(key) {
this.state[key] = !this.state[key]; this.state[key] = !this.state[key];
@@ -213,7 +225,10 @@ class DemoComponent extends Component {
} }
class App extends Component { class App extends Component {
state = useState({ n: 0, flag: true }); constructor() {
super(...arguments);
this.state = useState({ n: 0, flag: true });
}
increment() { increment() {
this.state.n++; this.state.n++;
@@ -282,11 +297,14 @@ function useMouse() {
// Main root component // Main root component
class App extends owl.Component { class App extends owl.Component {
// simple state hook (reactive object) constructor() {
counter = useState({ value: 0 }); super(...arguments);
// simple state hook (reactive object)
this.counter = useState({ value: 0 });
// this hooks is bound to the 'mouse' property. // this hooks is bound to the 'mouse' property.
mouse = useMouse(); this.mouse = useMouse();
}
increment() { increment() {
this.counter.value++; this.counter.value++;
@@ -318,7 +336,10 @@ const { Component, Context } = owl;
const { useContext } = owl.hooks; const { useContext } = owl.hooks;
class ToolbarButton extends Component { class ToolbarButton extends Component {
theme = useContext(this.env.themeContext); constructor() {
super(...arguments);
this.theme = useContext(this.env.themeContext);
}
get style () { get style () {
const theme = this.theme; const theme = this.theme;
@@ -451,12 +472,11 @@ const actions = {
// TodoItem // TodoItem
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
class TodoItem extends Component { class TodoItem extends Component {
state = useState({ isEditing: false }); constructor() {
dispatch = useDispatch(); super(...arguments);
constructor(...args) {
super(...args);
useAutofocus("input"); useAutofocus("input");
this.state = useState({ isEditing: false });
this.dispatch = useDispatch();
} }
handleKeyup(ev) { handleKeyup(ev) {
@@ -483,9 +503,12 @@ class TodoItem extends Component {
// TodoApp // TodoApp
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
class TodoApp extends Component { class TodoApp extends Component {
state = useState({ filter: "all" }); constructor() {
todos = useStore(state => state.todos); super(...arguments);
dispatch = useDispatch(); this.state = useState({ filter: "all" });
this.todos = useStore(state => state.todos);
this.dispatch = useDispatch();
}
get visibleTodos() { get visibleTodos() {
switch (this.state.filter) { switch (this.state.filter) {
@@ -1008,7 +1031,10 @@ class FormView extends owl.Component {}
FormView.components = { AdvancedComponent }; FormView.components = { AdvancedComponent };
class Chatter extends owl.Component { class Chatter extends owl.Component {
messages = Array.from(Array(100).keys()); constructor() {
super(...arguments);
this.messages = Array.from(Array(100).keys());
}
} }
class App extends owl.Component {} class App extends owl.Component {}
@@ -1150,7 +1176,10 @@ const SLOTS = `// We show here how slots can be used to create generic component
const { Component, useState } = owl; const { Component, useState } = owl;
class Card extends Component { class Card extends Component {
state = useState({ showContent: true }); constructor() {
super(...arguments);
this.state = useState({ showContent: true });
}
toggleDisplay() { toggleDisplay() {
this.state.showContent = !this.state.showContent; this.state.showContent = !this.state.showContent;
@@ -1158,7 +1187,10 @@ class Card extends Component {
} }
class Counter extends Component { class Counter extends Component {
state = useState({val: 1}); constructor() {
super(...arguments);
this.state = useState({val: 1});
}
inc() { inc() {
this.state.val++; this.state.val++;
@@ -1167,7 +1199,10 @@ class Counter extends Component {
// Main root component // Main root component
class App extends Component { class App extends Component {
state = useState({a: 1, b: 3}); constructor() {
super(...arguments);
this.state = useState({a: 1, b: 3});
}
inc(key, delta) { inc(key, delta) {
this.state[key] += delta; this.state[key] += delta;
@@ -1277,7 +1312,10 @@ class SlowComponent extends Component {
class NotificationList extends Component {} class NotificationList extends Component {}
class App extends Component { class App extends Component {
state = useState({ value: 0, notifs: [] }); constructor() {
super(...arguments);
this.state = useState({ value: 0, notifs: [] });
}
increment() { increment() {
this.state.value++; this.state.value++;
@@ -1350,13 +1388,16 @@ const FORM = `// This example illustrate how the t-model directive can be used t
const { Component, useState } = owl; const { Component, useState } = owl;
class Form extends Component { class Form extends Component {
state = useState({ constructor() {
text: "", super(...arguments);
othertext: "", this.state = useState({
number: 11, text: "",
color: "", othertext: "",
bool: false number: 11,
}); color: "",
bool: false
});
}
} }
// Application setup // Application setup
@@ -1403,6 +1444,111 @@ const FORM_XML = `<templates>
</templates> </templates>
`; `;
const PORTAL_COMPONENTS = `
// This shows the expected use case of Portal
// which is to implement something similar
// to bootstrap modal
const { Component, useState } = owl;
const { Portal } = owl.misc;
class Modal extends Component {}
Modal.components = { Portal };
class Dialog extends Component {}
Dialog.components = { Modal };
class Interstellar extends Component {}
// Main root component
class App extends Component {
state = useState({
name: 'Portal used for Dialog (Modal)',
dialog: false,
text: 'Hello !',
});
}
App.components = { Dialog , Interstellar };
// Application setup
const app = new App();
app.mount(document.body);
`;
const PORTAL_XML = `
<templates>
<t t-name="Modal">
<Portal target="'body'">
<div class="owl-modal-supercontainer">
<div class="owl-modal-backdrop"></div>
<div class="owl-modal-container">
<t t-slot="default" />
</div>
</div>
</Portal>
</t>
<t t-name="Dialog">
<Modal>
<div class="owl-dialog-body">
<t t-slot="default" />
</div>
</Modal>
</t>
<div t-name="Interstellar" class="owl-interstellar">
<h4>This is a subComponent</h4>
<p>The events it triggers will go through the Portal and be teleported
on the other side of the wormhole it has created</p>
<button t-on-click="trigger('collapse-all')">Close the wormhole</button>
</div>
<div t-name="App" t-on-collapse-all="state.dialog=false">
<div t-esc="state.name"/>
<button t-on-click="state.dialog = true">Open Dialog</button>
<Dialog t-if="state.dialog">
<div t-esc="state.text"/>
<Interstellar />
</Dialog>
</div>
</templates>
`;
const PORTAL_CSS = `
.owl-modal-supercontainer {
position: static;
}
.owl-modal-backdrop {
position: fixed;
top: 0;
left:0;
background-color: #000000;
opacity: 0.5;
width: 100vw;
height: 100vh;
z-index: 1000;
}
.owl-modal-container {
opacity:1;
z-index: 1050;
position: fixed;
top: 0;
left:0;
width: 100%;
height: 100%;
}
.owl-dialog-body {
max-width: 500px;
margin: 0 auto;
position: relative;
text-align: center;
padding: 2rem;
background-color: #FFFFFF;
max-height: 100%;
}
.owl-interstellar {
border: groove;
}`
const WMS = `// This example is slightly more complex than usual. We demonstrate const WMS = `// This example is slightly more complex than usual. We demonstrate
// here a way to manage sub windows in Owl, declaratively. This is still just a // here a way to manage sub windows in Owl, declaratively. This is still just a
// demonstration. Managing windows can be as complex as we want. For example, // demonstration. Managing windows can be as complex as we want. For example,
@@ -1419,7 +1565,10 @@ const { useRef } = owl.hooks;
class HelloWorld extends Component {} class HelloWorld extends Component {}
class Counter extends Component { class Counter extends Component {
state = useState({ value: 0 }); constructor() {
super(...arguments);
this.state = useState({ value: 0 });
}
inc() { inc() {
this.state.value++; this.state.value++;
@@ -1470,11 +1619,14 @@ class Window extends Component {
} }
class WindowManager extends Component { class WindowManager extends Component {
windows = []; constructor() {
nextId = 1; super(...arguments);
currentZindex = 1; this.windows = [];
nextLeft = 0; this.nextId = 1;
nextTop = 0; this.currentZindex = 1;
this.nextLeft = 0;
this.nextTop = 0;
}
addWindow(name) { addWindow(name) {
const info = this.env.windows.find(w => w.name === name); const info = this.env.windows.find(w => w.name === name);
@@ -1518,7 +1670,10 @@ class WindowManager extends Component {
WindowManager.components = { Window }; WindowManager.components = { Window };
class App extends Component { class App extends Component {
wmRef = useRef("wm"); constructor() {
super(...arguments);
this.wmRef = useRef("wm");
}
addWindow(name) { addWindow(name) {
this.wmRef.comp.addWindow(name); this.wmRef.comp.addWindow(name);
@@ -1552,7 +1707,7 @@ const WMS_XML = `<templates>
<div t-name="Window" class="window" t-att-style="style" t-on-click="updateZIndex"> <div t-name="Window" class="window" t-att-style="style" t-on-click="updateZIndex">
<div class="header"> <div class="header">
<span t-on-mousedown="startDragAndDrop"><t t-esc="props.info.title"/></span> <span t-on-mousedown="startDragAndDrop"><t t-esc="props.info.title"/></span>
<span class="close" t-on-click="close">×</span> <span class="close" t-on-click.stop="close">×</span>
</div> </div>
<t t-slot="default"/> <t t-slot="default"/>
</div> </div>
@@ -1720,5 +1875,11 @@ export const SAMPLES = [
code: ASYNC_COMPONENTS, code: ASYNC_COMPONENTS,
xml: ASYNC_COMPONENTS_XML, xml: ASYNC_COMPONENTS_XML,
css: ASYNC_COMPONENTS_CSS css: ASYNC_COMPONENTS_CSS
} },
{
description: "Portal (Dialog)",
code: PORTAL_COMPONENTS,
xml: PORTAL_XML,
css: PORTAL_CSS,
},
]; ];