From 5cdaa7b473dc2b30fc3f45f955782b05be8f80a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 26 Sep 2019 21:56:01 +0200 Subject: [PATCH] [IMP] hooks: implement useRef hook closes #194 --- README.md | 2 +- doc/component.md | 89 +++++++------ doc/hooks.md | 119 +++++++++++++----- doc/observer.md | 4 +- doc/readme.md | 1 + doc/tooling.md | 11 +- src/component/component.ts | 16 +-- src/component/directive.ts | 5 +- src/hooks.ts | 33 ++++- src/qweb/context.ts | 4 + src/qweb/extensions.ts | 3 +- tests/animations.test.ts | 5 +- .../__snapshots__/component.test.ts.snap | 9 +- tests/component/component.test.ts | 104 ++++++++------- tests/hooks.test.ts | 20 ++- tests/qweb/__snapshots__/qweb.test.ts.snap | 9 +- tests/qweb/qweb.test.ts | 8 +- tools/benchmarks/owl-master/app.js | 9 +- tools/playground/app.js | 13 +- tools/playground/samples.js | 28 ++++- 20 files changed, 321 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index da0d580e..04c4cbff 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ const app = new App({ qweb: new QWeb() }); app.mount(document.body); ``` -Note that the counter component is made reactive with the `useState` hook. +Note that the counter component is made reactive with the [`useState` hook](doc/hooks.md#usestate). More interesting examples can be found on the [playground](https://odoo.github.io/owl/playground) application. diff --git a/doc/component.md b/doc/component.md index 1524fe52..257574b3 100644 --- a/doc/component.md +++ b/doc/component.md @@ -47,7 +47,6 @@ exclusively done by a [QWeb](qweb.md) template (which needs to be preloaded in Q Rendering a component generates a virtual dom representation of the component, which is then patched to the DOM, in order to apply the changes in an efficient way. - ## Example Let us have a look at a simple component: @@ -79,7 +78,6 @@ Owl will use the component's name as template name. Here, a state object is defined, by using the `useState` hook. It is not mandatory to use the state object, but it is certainly encouraged. The result of the `useState` call is [observed](observer.md), and any change to it will cause a rerendering. - ## Reference An Owl component is a small class which represent a component or some UI element. @@ -122,9 +120,6 @@ constructor. in some case. Note that `props` are owned by the parent, not by the component. As such, it should not ever be modified by the component!! -- **`refs`** (Object): the `refs` object contains all references to sub DOM nodes - or sub components defined by a `t-ref` directive in the component's template. - ### Static Properties - **`template`** (string, optional): if given, this is the name of the QWeb template that will render the component. Note that there is a helper `xml` to @@ -517,24 +512,24 @@ class ParentComponent { ``` There is an even more dynamic way to use `t-component`: its value can be an -expression evaluating to an actual component class. In that case, this is the +expression evaluating to an actual component class. In that case, this is the class that will be used to create the component: ```js class A extends Component { - static template = xml`child a`; + static template = xml`child a`; } class B extends Component { - static template = xml`child b`; + static template = xml`child b`; } class App extends Component { - static template = xml``; + static template = xml``; - state = { child: "a" }; + state = { child: "a" }; - get myComponent() { - return this.state.child === "a" ? A : B; - } + get myComponent() { + return this.state.child === "a" ? A : B; + } } ``` @@ -967,44 +962,60 @@ Examples: ### References -The `t-ref` directive helps a component keep reference to some inside part of it. -Like the `t-on` directive, it can work either on a DOM node, or on a component: +The `useRef` hook is useful when we need a way to interact with some inside part +of a component, rendered by Owl. It can work either on a DOM node, or on a component, +tagged by the `t-ref` directive. See the [hooks section](hooks.md#useref) for +more detail. + +As a short example, here is how we could set the focus on a given input: ```xml
-
- + +
``` -In this example, the component will be able to access the `div` and the component -inside the special `refs` variable: - ```js -this.refs.someDiv; -this.refs.someComponent; +import { useRef } from "owl/hooks"; + +class SomeComponent extends Component { + inputRef = useRef("input"); + + focusInput() { + this.inputRef.el.focus(); + } +} ``` -This is useful for various usecases: for example, integrating with an external -library that needs to render itself inside an actual DOM node. Or for calling -some method on a sub component. - -Note: if used on a component, the reference will be set in the `refs` -variable between `willPatch` and `patched`. - -The `t-ref` directive also accepts dynamic values with string interpolation -(like the [`t-attf-`](qweb.md#dynamic-attributes) and -`t-component` directives). For example, if we have -`id` set to 44 in the rendering context, +The `useRef` hook can also be used to get a reference to an instance of a sub +component rendered by Owl. In that case, we need to access it with the `comp` +property instead of `el`: ```xml -
+
+ + +
``` ```js -this.refs.component_44; +import { useRef } from "owl/hooks"; + +class SomeComponent extends Component { + static components = { SubComponent }; + subRef = useRef("sub"); + + doSomething() { + this.subRef.comp.doSomeThingElse(); + } +} ``` +Note that these two examples uses the suffix `ref` to name the reference. This +is not mandatory, but it is a useful convention, so we do not forget to access +it with the `el` or `comp` suffix. + ### Slots To make generic components, it is useful to be able for a parent component to _inject_ @@ -1163,19 +1174,19 @@ env.qweb.on("error", null, function(error) { ### Functional Components -Owl does not exactly have functional components. However, there is an extremely +Owl does not exactly have functional components. However, there is an extremely close alternative: calling sub templates. A stateless functional component in react is usually some kind of function that maps props to a virtual dom (often with `jsx`). So, basically, almost like a -template rendered with `props`. In Owl, this can be done by +template rendered with `props`. In Owl, this can be done by simply defining a template, that will access the `props` object: ```js const Welcome = xml`

Hello, {props.name}

`; class MyComponent extends Component { - static template = xml` + static template = xml`
something
@@ -1186,4 +1197,4 @@ class MyComponent extends Component { The way this works is that sub templates are inlined, and have access to the ambient context. They can therefore access `props`, and any other part of the -caller component. \ No newline at end of file +caller component. diff --git a/doc/hooks.md b/doc/hooks.md index ee44cfa2..538e8bcd 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -5,14 +5,16 @@ - [Overview](#overview) - [Example](#example) - [Reference](#reference) - - [One Rule](#one-rule) - - [`useState`](#usestate) - - [`onMounted`](#onmounted) - - [`onWillUnmount`](#onWillUnmount) + - [One Rule](#one-rule) + - [`useState`](#usestate) + - [`onMounted`](#onmounted) + - [`onWillUnmount`](#onwillunmount) + - [`useRef`](#useref) ## Overview Hooks were popularised by React as a way to solve the following issues: + - help reusing stateful logic between components - help organizing code by feature in complex components - use state in functional components, without writing a class. @@ -22,51 +24,49 @@ Owl hooks serve the same purpose, except that they work for class components there seems to be the misconception that hooks are in opposition to class. This is clearly not true, as shown by Owl hooks). - Hooks works beautifully with Owl components: they solve the problems mentioned above, and in particular, they are the perfect way to make your component reactive. - ## Example Here is the classical example of a non trivial hook to track the mouse position. ```js -const {useState, onMounted, onWillUnmount} = owl.hooks; +const { useState, onMounted, onWillUnmount } = owl.hooks; // We define here a custom behaviour: this hook tracks the state of the mouse // position function useMouse() { - const position = useState({x:0, y: 0}); + const position = useState({ x: 0, y: 0 }); - function update(e) { - position.x = e.clientX; - position.y = e.clientY; - } - onMounted(() => { - window.addEventListener('mousemove', update); - }); - onWillUnmount(() => { - window.removeEventListener('mousemove', update); - }); + function update(e) { + position.x = e.clientX; + position.y = e.clientY; + } + onMounted(() => { + window.addEventListener("mousemove", update); + }); + onWillUnmount(() => { + window.removeEventListener("mousemove", update); + }); - return position; + return position; } // Main root component class App extends owl.Component { - static template = xml` + static template = xml`
Mouse: ,
`; - // this hooks is bound to the 'mouse' property. - mouse = useMouse(); + // this hooks is bound to the 'mouse' property. + mouse = useMouse(); } ``` -Note that we use the prefix `use` for hooks, just like in React. This is just +Note that we use the prefix `use` for hooks, just like in React. This is just a convention. ## Reference @@ -79,22 +79,22 @@ constructor (or in class fields): ```js // ok class SomeComponent extends Component { - state = useState(); + state = useState({value: 0}); } // also ok class SomeComponent extends Component { - constructor(...args) { - super(...args); - this.state = useState(); - } + constructor(...args) { + super(...args); + this.state = useState({value: 0}); + } } // not ok: this is executed after the constructor is called class SomeComponent extends Component { - async willStart() { - this.state = useState(); - } + async willStart() { + this.state = useState({value: 0}); + } } ``` @@ -126,11 +126,64 @@ class Counter extends owl.Component { ### `onMounted` `onMounted` is not an hook, but is a building block designed to help make useful -hooks. `onMounted` register a callback, which will be called when the component +hooks. `onMounted` register a callback, which will be called when the component is mounted (see example on top of this page). ### `onWillUnmount` `onWillUnmount` is not an hook, but is a building block designed to help make useful -hooks. `onWillUnmount` register a callback, which will be called when the component +hooks. `onWillUnmount` register a callback, which will be called when the component is unmounted (see example on top of this page). + +### `useRef` + +The `useRef` hook is useful when we need a way to interact with some inside part +of a component, rendered by Owl. It can work either on a DOM node, or on a component, +tagged by the `t-ref` directive: + +```xml +
+
+ +
+``` + +In this example, the component will be able to access the `div` and the component +`SubComponent` using the `useRef` hook: + +```js +class Parent extends Component { + subRef = useRef("someComponent"); + divRef = useRef("someDiv"); + + someMethod() { + // here, if component is mounted, refs are active: + // - this.divRef.el is the div HTMLElement + // - this.subRef.comp is the instance of the sub component + } +} +``` + +As shown by the example above, html elements are accessed by using the `el` +key, and components references are accessed with `comp`. + +Note: if used on a component, the reference will be set in the `refs` +variable between `willPatch` and `patched`. + +The `t-ref` directive also accepts dynamic values with string interpolation +(like the [`t-attf-`](qweb.md#dynamic-attributes) and +`t-component` directives). For example, + +```xml +
+``` + +Here, the references needs to be set like this: + +```js +this.ref1 = useRef("component_1"); +this.ref2 = useRef("component_2"); +``` + +References are only guaranteed to be active while the parent component is mounted. +If this is not the case, accessing `el` or `comp` on it will return `null`. diff --git a/doc/observer.md b/doc/observer.md index cf0a8788..15b4a735 100644 --- a/doc/observer.md +++ b/doc/observer.md @@ -11,10 +11,10 @@ For example, this code will display `update` in the console: ```javascript const observer = new owl.Observer(); observer.notifyCB = () => console.log("update"); -const obj = observer.observe( { a: { b: 1 } }); +const obj = observer.observe({ a: { b: 1 } }); obj.a.b = 2; ``` -The observer is implemented with the native `Proxy` object. Note that this +The observer is implemented with the native `Proxy` object. Note that this means that it will not work on older browsers. diff --git a/doc/readme.md b/doc/readme.md index 6f6f5964..acf566d8 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -17,6 +17,7 @@ owl onMounted onWillUnmount useState + useRef router Link RouteComponent diff --git a/doc/tooling.md b/doc/tooling.md index f768833c..5d3e7398 100644 --- a/doc/tooling.md +++ b/doc/tooling.md @@ -61,10 +61,9 @@ useful to compare various performance metrics on some tasks. If you want to have `xml` syntax highlighting while using the `xml` helper which helps you define inline templates, there is a VS Code addon `Comment tagged template` -which, if installed, does exactly that. To enable it, you need to add a comment, +which, if installed, does exactly that. To enable it, you need to add a comment, like this: - ```js // ----------------------------------------------------------------------------- // TEMPLATE @@ -79,9 +78,9 @@ const TEMPLATE = xml/* xml */ ` // CODE // ----------------------------------------------------------------------------- class MyComponent extends Component { - static template = TEMPLATE; - static components = { Sidebar, Content }; + static template = TEMPLATE; + static components = { Sidebar, Content }; - // rest of component... + // rest of component... } -``` \ No newline at end of file +``` diff --git a/src/component/component.ts b/src/component/component.ts index 2c73795b..33a025d4 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -85,6 +85,7 @@ interface Internal { render: CompiledTemplate | null; mountedHandlers: { [key: number]: Function }; classObj: { [key: string]: boolean } | null; + refs: { [key: string]: Component | HTMLElement | undefined } | null; } //------------------------------------------------------------------------------ @@ -97,6 +98,9 @@ export class Component { static template?: string | null = null; static _template?: string | null = null; static _current?: any | null = null; + static components = {}; + static props?: any; + static defaultProps?: any; /** * The `el` is the root element of the component. Note that it could be null: @@ -105,19 +109,10 @@ export class Component { get el(): HTMLElement | null { return this.__owl__.vnode ? (this).__owl__.vnode.elm : null; } - static components = {}; env: T; props: Props; - // type of props is not easily representable in typescript... - static props?: any; - static defaultProps?: any; - - refs: { - [key: string]: Component | HTMLElement | undefined; - } = {}; - //-------------------------------------------------------------------------- // Lifecycle //-------------------------------------------------------------------------- @@ -192,7 +187,8 @@ export class Component { mountedHandlers: {}, observer: null, render: null, - classObj: null + classObj: null, + refs: null }; } diff --git a/src/component/directive.ts b/src/component/directive.ts index ec5d2970..954109d2 100644 --- a/src/component/directive.ts +++ b/src/component/directive.ts @@ -263,9 +263,10 @@ QWeb.addDirective({ let refExpr = ""; let refKey: string = ""; if (ref) { + ctx.rootContext.shouldDefineRefs = true; refKey = `ref${ctx.generateID()}`; ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`); - refExpr = `context.refs[${refKey}] = w${componentID};`; + refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`; } let transitionsInsertCode = ""; if (transition) { @@ -273,7 +274,7 @@ QWeb.addDirective({ } let finalizeComponentCode = `w${componentID}.${keepAlive ? "unmount" : "destroy"}();`; if (ref && !keepAlive) { - finalizeComponentCode += `delete context.refs[${refKey}];`; + finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`; } if (transition) { finalizeComponentCode = `let finalize = () => { diff --git a/src/hooks.ts b/src/hooks.ts index c1c53b7a..a291c454 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -9,9 +9,9 @@ import { Observer } from "./core/observer"; * - useState (reactive state) * - onMounted * - onWillUnmount + * - useRef */ - /** * useState hook * @@ -20,7 +20,7 @@ import { Observer } from "./core/observer"; * trigger a rerendering of the current component. */ export function useState(state: T): T { - const component: Component = Component._current; + const component: Component = Component._current; const __owl__ = component.__owl__; if (!__owl__.observer) { __owl__.observer = new Observer(); @@ -34,7 +34,7 @@ export function useState(state: T): T { * mounted. Note that the component mounted method is called first. */ export function onMounted(cb) { - const component = Component._current; + const component: Component = Component._current; const current = component.mounted; component.mounted = function() { current.call(component); @@ -47,10 +47,35 @@ export function onMounted(cb) { * willUnmounted. Note that the component mounted method is called last. */ export function onWillUnmount(cb) { - const component = Component._current; + const component: Component = Component._current; const current = component.willUnmount; component.willUnmount = function() { cb(); current.call(component); }; } + +/** + * useRef hook + * + * The purpose of this hook is to allow components to get a reference to a sub + * html node or component. + */ +interface Ref { + el: HTMLElement | null; + comp: Component | null; +} + +export function useRef(name: string): Ref { + const __owl__ = Component._current.__owl__; + return { + get el(): HTMLElement | null { + const val = __owl__.refs && __owl__.refs[name]; + return val instanceof HTMLElement ? val : null; + }, + get comp(): Component | null { + const val = __owl__.refs && __owl__.refs[name]; + return val instanceof Component ? val : null; + } + }; +} diff --git a/src/qweb/context.ts b/src/qweb/context.ts index 3eaaf6b1..fd70f8de 100644 --- a/src/qweb/context.ts +++ b/src/qweb/context.ts @@ -20,6 +20,7 @@ export class Context { shouldDefineParent: boolean = false; shouldDefineQWeb: boolean = false; shouldDefineUtils: boolean = false; + shouldDefineRefs: boolean = false; shouldDefineResult: boolean = true; shouldProtectContext: boolean = false; shouldTrackScope: boolean = false; @@ -62,6 +63,9 @@ export class Context { if (this.shouldDefineResult) { this.code.unshift(" let result;"); } + if (this.shouldDefineRefs) { + this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};"); + } if (this.shouldDefineOwner) { // this is necessary to prevent some directives (t-forach for ex) to // pollute the rendering context by adding some keys in it. diff --git a/src/qweb/extensions.ts b/src/qweb/extensions.ts index 9843fd52..0e1c2016 100644 --- a/src/qweb/extensions.ts +++ b/src/qweb/extensions.ts @@ -79,9 +79,10 @@ QWeb.addDirective({ name: "ref", priority: 95, atNodeCreation({ ctx, value, addNodeHook }) { + ctx.rootContext.shouldDefineRefs = true; const refKey = `ref${ctx.generateID()}`; ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`); - addNodeHook("create", `context.refs[${refKey}] = n.elm;`); + addNodeHook("create", `context.__owl__.refs[${refKey}] = n.elm;`); } }); diff --git a/tests/animations.test.ts b/tests/animations.test.ts index f08e07fd..7b51cf50 100644 --- a/tests/animations.test.ts +++ b/tests/animations.test.ts @@ -1,6 +1,6 @@ import { Component, Env } from "../src/component/component"; import { QWeb } from "../src/qweb/index"; -import { useState } from "../src/hooks"; +import { useState, useRef } from "../src/hooks"; import { makeDeferred, makeTestFixture, @@ -150,6 +150,7 @@ describe("animations", () => { ); class TestWidget extends Widget { state = useState({ hide: false }); + span = useRef("span"); } const widget = new TestWidget(env); @@ -164,7 +165,7 @@ describe("animations", () => { }); await widget.mount(fixture); spanNode = widget.el!.children[0]; - expect(widget.refs.span).toBe(spanNode); + expect(widget.span.el).toBe(spanNode); expect(spanNode.className).toBe("chimay-enter chimay-enter-active"); await def; // wait for the mocked repaint to be done spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition diff --git a/tests/component/__snapshots__/component.test.ts.snap b/tests/component/__snapshots__/component.test.ts.snap index e29f573b..dfaefdc0 100644 --- a/tests/component/__snapshots__/component.test.ts.snap +++ b/tests/component/__snapshots__/component.test.ts.snap @@ -299,6 +299,7 @@ exports[`class and style attributes with t-component t-att-class is properly add let QWeb = this.constructor; let parent = context; let owner = context; + context.__owl__.refs = context.__owl__.refs || {}; var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); @@ -327,7 +328,7 @@ exports[`class and style attributes with t-component t-att-class is properly add w4 = new W4(parent, props4); parent.__owl__.cmap[4] = w4.__owl__.id; def3 = w4.__prepare(extra.fiber, undefined, undefined); - def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}vnode.data.hook = {create(_, vn){}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;context.refs[ref5] = w4;},remove() {},destroy(vn) {w4.destroy();delete context.refs[ref5];}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}vnode.data.hook = {create(_, vn){}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;context.__owl__.refs[ref5] = w4;},remove() {},destroy(vn) {w4.destroy();delete context.__owl__.refs[ref5];}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); } else { def3 = def3 || w4.__updateProps(props4, extra.fiber, undefined, undefined); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); @@ -359,6 +360,7 @@ exports[`class and style attributes with t-component t-att-class is properly add let QWeb = this.constructor; let parent = context; let owner = context; + context.__owl__.refs = context.__owl__.refs || {}; var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); @@ -387,7 +389,7 @@ exports[`class and style attributes with t-component t-att-class is properly add w4 = new W4(parent, props4); parent.__owl__.cmap[4] = w4.__owl__.id; def3 = w4.__prepare(extra.fiber, undefined, undefined); - def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}vnode.data.hook = {create(_, vn){}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;context.refs[ref5] = w4;},remove() {},destroy(vn) {w4.destroy();delete context.refs[ref5];}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}vnode.data.hook = {create(_, vn){}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;context.__owl__.refs[ref5] = w4;},remove() {},destroy(vn) {w4.destroy();delete context.__owl__.refs[ref5];}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); } else { def3 = def3 || w4.__updateProps(props4, extra.fiber, undefined, undefined); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); @@ -1552,6 +1554,7 @@ exports[`t-slot directive refs are properly bound in slots 1`] = ` "function anonymous(context,extra ) { let owner = context; + context.__owl__.refs = context.__owl__.refs || {}; var h = this.h; let c1 = extra.parentNode; Object.assign(context, extra.fiber.scope); @@ -1566,7 +1569,7 @@ exports[`t-slot directive refs are properly bound in slots 1`] = ` const ref11 = \`myButton\`; p10.hook = { create: (_, n) => { - context.refs[ref11] = n.elm; + context.__owl__.refs[ref11] = n.elm; }, }; c10.push({text: \`do something\`}); diff --git a/tests/component/component.test.ts b/tests/component/component.test.ts index 38653af2..81a1f878 100644 --- a/tests/component/component.test.ts +++ b/tests/component/component.test.ts @@ -1,7 +1,7 @@ import { Component, Env } from "../../src/component/component"; import { QWeb } from "../../src/qweb/qweb"; import { xml } from "../../src/tags"; -import { useState } from "../../src/hooks"; +import { useState, useRef } from "../../src/hooks"; import { EventBus } from "../../src/core/event_bus"; import { makeDeferred, @@ -989,13 +989,15 @@ describe("composition", () => { }); test("t-refs on widget are components", async () => { - env.qweb.addTemplate("WidgetC", `
Hello
`); class WidgetC extends Widget { - static components = { b: WidgetB }; + static template = xml`
Hello
`; + static components = { WidgetB }; + widget = useRef("mywidgetb"); } + const widget = new WidgetC(env); await widget.mount(fixture); - expect(widget.refs.mywidgetb instanceof WidgetB).toBe(true); + expect(widget.widget.comp).toBeInstanceOf(WidgetB); }); test("t-refs are bound at proper timing", async () => { @@ -1008,11 +1010,12 @@ describe("composition", () => { `; static components = { Widget }; state = useState({ list: [] }); + child = useRef("child"); willPatch() { - expect(this.refs.child).toBeUndefined(); + expect(this.child.comp).toBeNull(); } patched() { - expect(this.refs.child).not.toBeUndefined(); + expect(this.child.comp).not.toBeNull(); } } @@ -1035,29 +1038,31 @@ describe("composition", () => { class ParentWidget extends Widget { static components = { Widget }; state = useState({ child1: true, child2: false }); + child1 = useRef("child1"); + child2 = useRef("child2"); count = 0; mounted() { - expect(this.refs.child1).toBeDefined(); - expect(this.refs.child2).toBeUndefined(); + expect(this.child1.comp).toBeDefined(); + expect(this.child2.comp).toBeNull(); } willPatch() { if (this.count === 0) { - expect(this.refs.child1).toBeDefined(); - expect(this.refs.child2).toBeUndefined(); + expect(this.child1.comp).toBeDefined(); + expect(this.child2.comp).toBeNull(); } if (this.count === 1) { - expect(this.refs.child1).toBeDefined(); - expect(this.refs.child2).toBeDefined(); + expect(this.child1.comp).toBeDefined(); + expect(this.child2.comp).toBeDefined(); } } patched() { if (this.count === 0) { - expect(this.refs.child1).toBeDefined(); - expect(this.refs.child2).toBeDefined(); + expect(this.child1.comp).toBeDefined(); + expect(this.child2.comp).toBeDefined(); } if (this.count === 1) { - expect(this.refs.child1).toBeUndefined(); - expect(this.refs.child2).toBeDefined(); + expect(this.child1.comp).toBeNull(); + expect(this.child2.comp).toBeDefined(); } this.count++; } @@ -1095,12 +1100,19 @@ describe("composition", () => {
` ); class ParentWidget extends Widget { - state = useState({ items: [1, 2, 3] }); static components = { Child: Widget }; + elem1 = useRef("1"); + elem2 = useRef("2"); + elem3 = useRef("3"); + elem4 = useRef("4"); + state = useState({ items: [1, 2, 3] }); } const parent = new ParentWidget(env); await parent.mount(fixture); - expect(Object.keys(parent.refs)).toEqual(["1", "2", "3"]); + expect(parent.elem1.comp).toBeDefined(); + expect(parent.elem2.comp).toBeDefined(); + expect(parent.elem3.comp).toBeDefined(); + expect(parent.elem4.comp).toBeNull(); }); test("parent's elm for a children === children's elm, even after rerender", async () => { @@ -1217,36 +1229,37 @@ describe("composition", () => { }); test("sub widget with t-ref and t-keepalive", async () => { - env.qweb.addTemplates(` - -
- -
- Hello -
`); - class ChildWidget extends Widget {} + class ChildWidget extends Widget { + static template = xml`Hello`; + } class ParentWidget extends Widget { - state = useState({ ok: true }); + static template = xml` +
+ +
+ `; static components = { ChildWidget }; + state = useState({ ok: true }); + child = useRef("child"); } const widget = new ParentWidget(env); await widget.mount(fixture); let child = children(widget)[0]; expect(fixture.innerHTML).toBe("
Hello
"); - expect(widget.refs.child).toEqual(child); + expect(widget.child.comp).toEqual(child); widget.state.ok = false; await nextTick(); expect(fixture.innerHTML).toBe("
"); - expect(widget.refs.child).toEqual(child); + expect(widget.child.comp).toEqual(child); widget.state.ok = true; await nextTick(); expect(fixture.innerHTML).toBe("
Hello
"); - expect(widget.refs.child).toEqual(child); + expect(widget.child.comp).toEqual(child); }); test("sub components rendered in a loop", async () => { @@ -1582,6 +1595,7 @@ describe("class and style attributes with t-component", () => { class ParentWidget extends Widget { static components = { Child }; state = useState({ b: true }); + child = useRef("child"); } const widget = new ParentWidget(env); await widget.mount(fixture); @@ -1593,7 +1607,7 @@ describe("class and style attributes with t-component", () => { await nextTick(); expect(span.className).toBe("c d a"); - (widget.refs.child).state.d = false; + (widget.child.comp as Child).state.d = false; await nextTick(); expect(span.className).toBe("c a"); @@ -1601,7 +1615,7 @@ describe("class and style attributes with t-component", () => { await nextTick(); expect(span.className).toBe("c a b"); - (widget.refs.child).state.d = true; + (widget.child.comp as Child).state.d = true; await nextTick(); expect(span.className).toBe("c a b d"); expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot(); @@ -1623,6 +1637,7 @@ describe("class and style attributes with t-component", () => { class ParentWidget extends Widget { static components = { Child }; state = useState({ b: true }); + child = useRef("child"); } const widget = new ParentWidget(env); await widget.mount(fixture); @@ -1634,7 +1649,7 @@ describe("class and style attributes with t-component", () => { await nextTick(); expect(span.className).toBe("c d a"); - (widget.refs.child).state.d = false; + (widget.child.comp as Child).state.d = false; await nextTick(); expect(span.className).toBe("c a"); @@ -1642,7 +1657,7 @@ describe("class and style attributes with t-component", () => { await nextTick(); expect(span.className).toBe("c a b"); - (widget.refs.child).state.d = true; + (widget.child.comp as Child).state.d = true; await nextTick(); expect(span.className).toBe("c a b d"); expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot(); @@ -3000,12 +3015,13 @@ describe("t-mounted directive", () => { test("combined with a t-ref", async () => { env.qweb.addTemplate("TestWidget", `
`); class TestWidget extends Widget { + input = useRef("input"); f() {} } const widget = new TestWidget(env); widget.f = jest.fn(); await widget.mount(fixture); - expect(widget.refs.input).toBeDefined(); + expect(widget.input.el).toBeDefined(); expect(widget.f).toHaveBeenCalledTimes(1); }); }); @@ -3233,21 +3249,21 @@ describe("t-slot directive", () => { }); test("refs are properly bound in slots", async () => { - env.qweb.addTemplates(` - -
+ class Dialog extends Widget { + static template = xml``; + } + class Parent extends Widget { + static template = xml` +
- - - `); - class Dialog extends Widget {} - class Parent extends Widget { + `; static components = { Dialog }; state = useState({ val: 0 }); + button = useRef("myButton"); doSomething() { this.state.val++; } @@ -3259,7 +3275,7 @@ describe("t-slot directive", () => { '
0
' ); - (parent.refs.myButton).click(); + parent.button.el!.click(); await nextTick(); expect(fixture.innerHTML).toBe( diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index b05d7ea9..0168330d 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -1,6 +1,6 @@ import { makeTestEnv, makeTestFixture, nextTick } from "./helpers"; import { Component, Env } from "../src/component/component"; -import { useState, onMounted, onWillUnmount } from "../src/hooks"; +import { useState, onMounted, onWillUnmount, useRef } from "../src/hooks"; import { xml } from "../src/tags"; //------------------------------------------------------------------------------ @@ -129,4 +129,22 @@ describe("hooks", () => { "hook:willunmount1" ]); }); + + test("useRef hook", async () => { + class Counter extends Component { + static template = xml`
`; + button = useRef("button"); + value = 0; + increment() { + this.value++; + (this.button.el as HTMLButtonElement).innerHTML = String(this.value); + } + } + const counter = new Counter(env); + await counter.mount(fixture); + expect(fixture.innerHTML).toBe("
"); + counter.increment(); + await nextTick(); + expect(fixture.innerHTML).toBe("
"); + }); }); diff --git a/tests/qweb/__snapshots__/qweb.test.ts.snap b/tests/qweb/__snapshots__/qweb.test.ts.snap index f7182df7..4bc8d353 100644 --- a/tests/qweb/__snapshots__/qweb.test.ts.snap +++ b/tests/qweb/__snapshots__/qweb.test.ts.snap @@ -1915,6 +1915,7 @@ exports[`t-raw variable 1`] = ` exports[`t-ref can get a dynamic ref on a node 1`] = ` "function anonymous(context,extra ) { + context.__owl__.refs = context.__owl__.refs || {}; var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); @@ -1925,7 +1926,7 @@ exports[`t-ref can get a dynamic ref on a node 1`] = ` const ref3 = \`myspan\${context['id']}\`; p2.hook = { create: (_, n) => { - context.refs[ref3] = n.elm; + context.__owl__.refs[ref3] = n.elm; }, }; return vn1; @@ -1935,6 +1936,7 @@ exports[`t-ref can get a dynamic ref on a node 1`] = ` exports[`t-ref can get a ref on a node 1`] = ` "function anonymous(context,extra ) { + context.__owl__.refs = context.__owl__.refs || {}; var h = this.h; let c1 = [], p1 = {key:1}; var vn1 = h('div', p1, c1); @@ -1945,7 +1947,7 @@ exports[`t-ref can get a ref on a node 1`] = ` const ref3 = \`myspan\`; p2.hook = { create: (_, n) => { - context.refs[ref3] = n.elm; + context.__owl__.refs[ref3] = n.elm; }, }; return vn1; @@ -1955,6 +1957,7 @@ exports[`t-ref can get a ref on a node 1`] = ` exports[`t-ref refs in a loop 1`] = ` "function anonymous(context,extra ) { + context.__owl__.refs = context.__owl__.refs || {}; context = Object.create(context); var h = this.h; let c1 = [], p1 = {key:1}; @@ -1981,7 +1984,7 @@ exports[`t-ref refs in a loop 1`] = ` const ref6 = (context['item']); p5.hook = { create: (_, n) => { - context.refs[ref6] = n.elm; + context.__owl__.refs[ref6] = n.elm; }, }; var _7 = context['item']; diff --git a/tests/qweb/qweb.test.ts b/tests/qweb/qweb.test.ts index 16e1b3a1..57d3d1ed 100644 --- a/tests/qweb/qweb.test.ts +++ b/tests/qweb/qweb.test.ts @@ -1092,14 +1092,14 @@ describe("t-ref", () => { test("can get a ref on a node", () => { qweb.addTemplate("test", `
`); let refs: any = {}; - renderToDOM(qweb, "test", { refs }); + renderToDOM(qweb, "test", { __owl__: { refs } }); expect(refs.myspan.tagName).toBe("SPAN"); }); test("can get a dynamic ref on a node", () => { qweb.addTemplate("test", `
`); let refs: any = {}; - renderToDOM(qweb, "test", { refs, id: 3 }); + renderToDOM(qweb, "test", { id: 3, __owl__: { refs } }); expect(refs.myspan3.tagName).toBe("SPAN"); }); @@ -1114,7 +1114,7 @@ describe("t-ref", () => {
` ); let refs: any = {}; - renderToDOM(qweb, "test", { refs, items: [1, 2, 3] }); + renderToDOM(qweb, "test", { items: [1, 2, 3], __owl__: { refs } }); expect(Object.keys(refs)).toEqual(["1", "2", "3"]); }); }); @@ -1333,5 +1333,5 @@ describe("properly support svg", () => { expect(renderToString(qweb, "test")).toBe( ` ` ); -}); + }); }); diff --git a/tools/benchmarks/owl-master/app.js b/tools/benchmarks/owl-master/app.js index 18020a68..82c36ed6 100644 --- a/tools/benchmarks/owl-master/app.js +++ b/tools/benchmarks/owl-master/app.js @@ -1,6 +1,6 @@ import { buildData, startMeasure, stopMeasure, formatNumber } from "../shared/utils.js"; -const useState = owl.hooks.useState; +const { useState, useRef } = owl.hooks; //------------------------------------------------------------------------------ // Likes Counter Widget //------------------------------------------------------------------------------ @@ -34,6 +34,7 @@ class Message extends owl.Component { class App extends owl.Component { static components = { Message }; state = useState({ messages: [], multipleFlag: false, clearAfterFlag: false }); + logRef = useRef("log"); mounted() { this.log(`Benchmarking Owl v${owl.__info__.version} (build date: ${owl.__info__.date})`); @@ -130,12 +131,12 @@ class App extends owl.Component { div.classList.add("bold"); } div.textContent = `> ${str}`; - this.refs.log.appendChild(div); - this.refs.log.scrollTop = this.refs.log.scrollHeight; + this.logRef.el.appendChild(div); + this.logRef.el.scrollTop = this.logRef.el.scrollHeight; } clearLog() { - this.refs.log.innerHTML = ""; + this.logRef.el.innerHTML = ""; } } diff --git a/tools/playground/app.js b/tools/playground/app.js index be42db16..dab8a424 100644 --- a/tools/playground/app.js +++ b/tools/playground/app.js @@ -1,5 +1,5 @@ import { SAMPLES } from "./samples.js"; -const useState = owl.hooks.useState; +const {useState, useRef} = owl.hooks; //------------------------------------------------------------------------------ // Constants, helpers, utils //------------------------------------------------------------------------------ @@ -163,11 +163,11 @@ class TabbedEditor extends owl.Component { this.sessions = {}; this._setupSessions(props); - this.editor = null; + this.editorNode = useRef("editor"); } mounted() { - this.editor = this.editor || ace.edit(this.refs.editor); + this.editor = this.editor || ace.edit(this.editorNode.el); this.editor.setValue(this.props[this.state.currentTab], -1); this.editor.setFontSize("12px"); @@ -264,13 +264,14 @@ class App extends owl.Component { this.toggleLayout = owl.utils.debounce(this.toggleLayout, 250, true); this.runCode = owl.utils.debounce(this.runCode, 250, true); this.downloadCode = owl.utils.debounce(this.downloadCode, 250, true); + this.content = useRef("content"); } displayError(error) { this.state.error = error; if (error) { setTimeout(() => { - this.refs.content.innerHTML = ""; + this.content.el.innerHTML = ""; }); return; } @@ -296,8 +297,8 @@ class App extends owl.Component { } else { this.state.error = false; } - this.refs.content.innerHTML = ""; - this.refs.content.appendChild(subiframe); + this.content.el.innerHTML = ""; + this.content.el.appendChild(subiframe); } setSample(ev) { diff --git a/tools/playground/samples.js b/tools/playground/samples.js index ab48ca1a..f7ba4b57 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -325,6 +325,7 @@ const TODO_APP_STORE = `// This example is an implementation of the TodoList app // In this implementation, we use the owl Store class to manage the state. It // is very similar to the VueX store. const { Component, useState } = owl; +const { useRef } = owl.hooks; const ENTER_KEY = 13; const ESC_KEY = 27; @@ -397,6 +398,7 @@ function makeStore() { //------------------------------------------------------------------------------ class TodoItem extends Component { state = useState({ isEditing: false }); + inputRef = useRef("input"); removeTodo() { this.env.store.dispatch("removeTodo", this.props.id); @@ -411,9 +413,9 @@ class TodoItem extends Component { } focusInput() { - this.refs.input.value = ""; - this.refs.input.focus(); - this.refs.input.value = this.props.title; + this.inputRef.el.value = ""; + this.inputRef.el.focus(); + this.inputRef.el.value = this.props.title; } handleKeyup(ev) { @@ -1380,6 +1382,7 @@ const WMS = `// This example is slightly more complex than usual. We demonstrate // - better heuristic for initial window position // - ... const { Component, useState } = owl; +const { useRef } = owl.hooks; class HelloWorld extends Component {} @@ -1392,14 +1395,17 @@ class Counter extends Component { } class Window extends Component { + get style() { let { width, height, top, left, zindex } = this.props.info; return \`width: \${width}px;height: \${height}px;top:\${top}px;left:\${left}px;z-index:\${zindex}\`; } + close() { this.trigger("close-window", { id: this.props.info.id }); } + startDragAndDrop(ev) { this.updateZIndex(); this.el.classList.add('dragging'); @@ -1425,6 +1431,7 @@ class Window extends Component { self.trigger("set-window-position", options); } } + updateZIndex() { this.trigger("update-z-index", { id: this.props.info.id }); } @@ -1435,20 +1442,26 @@ class WindowManager extends Component { windows = []; nextId = 1; currentZindex = 1; + nextLeft = 0; + nextTop = 0; + addWindow(name) { const info = this.env.windows.find(w => w.name === name); + this.nextLeft = this.nextLeft + 30; + this.nextTop = this.nextTop + 30; this.windows.push({ id: this.nextId++, title: info.title, width: info.defaultWidth, height: info.defaultHeight, - top: 0, - left: 0, + top: this.nextTop, + left: this.nextLeft, zindex: this.currentZindex++, component: info.component }); this.render(); } + closeWindow(ev) { const id = ev.detail.id; delete this.constructor.components[id]; @@ -1456,12 +1469,14 @@ class WindowManager extends Component { this.windows.splice(index, 1); this.render(); } + setWindowPosition(ev) { const id = ev.detail.id; const w = this.windows.find(w => w.id === id); w.top = ev.detail.top; w.left = ev.detail.left; } + updateZIndex(ev) { const id = ev.detail.id; const w = this.windows.find(w => w.id === id); @@ -1472,9 +1487,10 @@ class WindowManager extends Component { class App extends Component { static components = { WindowManager }; + wmRef = useRef("wm"); addWindow(name) { - this.refs.wm.addWindow(name); + this.wmRef.comp.addWindow(name); } }