[IMP] hooks/Context: add Context and useContext

Closes #310
This commit is contained in:
Géry Debongnie
2019-10-08 09:00:06 +02:00
parent 17ce73e6a9
commit 1c3c2e5c51
8 changed files with 452 additions and 47 deletions
+108
View File
@@ -0,0 +1,108 @@
# 🦉 Context 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [`Context`](#context)
- [`useContext`](#usecontext)
## Overview
The `Context` object provides a way to share data between an arbitrary number
of component. Usually, data is passed from a parent to its children component,
but when we have to deal with some mostly global information, this can be
annoying, since each component will need to pass the information to each children,
even though some or most of them will not use the information.
With a `Context` object, each component can subscribe (with the `useContext` hook)
to its state, and will be updated whenever the context state is updated.
## Example
Assume that we have an application with various components which needs to render
differently depending on the size of the device. Here is how we could proceed
to make sure that the information is properly shared. First, let us create a
context, and add it to the environment:
```js
const deviceContext = new Context({ isMobile: true });
const env = {
qweb: new QWeb(TEMPLATES),
deviceContext
};
```
If we want to make it completely responsive, we need to update its value whenever
the size of the screen is updated:
```js
const isMobile = () => window.innerWidth <= 768;
window.addEventListener(
"resize",
owl.utils.debounce(() => {
const state = deviceContext.state;
if (state.isMobile !== isMobile()) {
state.isMobile = !state.isMobile;
}
}, 15)
);
```
Then, each component that want can subscribe and render differently depending on the
fact that we are in a mobile or desktop mode.
```js
class SomeComponent extends Component {
static template = xml`
<div>
<t t-if=device.isMobile>
some simplified user interface
</t>
<t t-else="1">
some more sopthisticated user interface
</t>
`;
device = useContext(this.env.deviceContext);
}
```
## Reference
### `Context`
A `Context` object should be created with a state object:
```js
const someContext = new Context({ some: "key" });
```
Its state is now available in the `state` key:
```js
someContext.state.some = "other key";
```
This is the way some global code (such as the responsive code above) should
read and update the context state. However, components should not ever read the
context state directly from the context, they should instead use the `useContext`
hook to properly register themselves to state changes.
Note that the `Context` hook is different from the React version. For example,
there is no concept of provider/consumer. So, the `Context` feature does not
by itself allow the use of a different context state depending on the component
place in the component tree. However, this functionality can be obtained, if
necessary, with the use of sub environment.
### `useContext`
The `useContext` hook is the normal way for a component to register themselve
to context state changes. The `useContext` method returns the context state:
```js
device = useContext(this.env.deviceContext);
```
It is a simple observed state (with an owl `Observer`), which contains the shared
information.
+8 -4
View File
@@ -12,6 +12,7 @@
- [`onWillUnmount`](#onwillunmount)
- [`onWillPatch`](#onwillpatch)
- [`onPatched`](#onpatched)
- [`useContext`](#usecontext)
- [`useRef`](#useref)
- [`useSubEnv`](#usesubenv)
@@ -182,7 +183,6 @@ is mounted (see example on top of this page).
abstractions. `onWillUnmount` registers a callback, which will be called when the component
is unmounted (see example on top of this page).
### `onWillPatch`
`onWillPatch` is not an user hook, but is a building block designed to help make useful
@@ -195,6 +195,10 @@ before the component patched.
abstractions. `onPatched` registers a callback, which will be called just
after the component patched.
### `useContext`
See [`useContext`](context.md#usecontext) for reference documentation.
### `useRef`
The `useRef` hook is useful when we need a way to interact with some inside part
@@ -251,7 +255,7 @@ If this is not the case, accessing `el` or `comp` on it will return `null`.
### `useSubEnv`
The environment is sometimes useful to share some common information between
all components. But sometimes, we want to *scope* that knowledge to a subtree.
all components. But sometimes, we want to _scope_ that knowledge to a subtree.
For example, if we have a form view component, maybe we would like to make some
`model` object available to all sub component, but not to the whole application.
@@ -271,5 +275,5 @@ class FormComponent extends Component {
The `useSubEnv` takes one argument: an object which contains some key/value that
will be added to the parent environment. Note that it will extend, not replace
the parent environment. And of course, the parent environment will not be
affected.
the parent environment. And of course, the parent environment will not be
affected.
+3
View File
@@ -8,6 +8,7 @@ build applications. Here is a complete representation of its content:
```
owl
Component
Context
QWeb
useState
core
@@ -18,6 +19,7 @@ owl
onWillUnmount
onWillPatch
onPatched
useContext
useState
useRef
useSubEnv
@@ -44,6 +46,7 @@ Note that for convenience, the `useState` hook is also exported at the root of t
- [Animations](animations.md)
- [Component](component.md)
- [Context](context.md)
- [Event Bus](event_bus.md)
- [Hooks](hooks.md)
- [Observer](observer.md)
+86
View File
@@ -0,0 +1,86 @@
import { Component } from "./component/component";
import { Observer } from "./core/observer";
import { EventBus } from "./core/event_bus";
import { onWillUnmount } from "./hooks";
/**
* The `Context` object provides a way to share data between an arbitrary number
* of component. Usually, data is passed from a parent to its children component,
* but when we have to deal with some mostly global information, this can be
* annoying, since each component will need to pass the information to each
* children, even though some or most of them will not use the information.
*
* With a `Context` object, each component can subscribe (with the `useContext`
* hook) to its state, and will be updated whenever the context state is updated.
*/
export class Context extends EventBus {
state: any;
observer: Observer;
id: number = 1;
// mapping from component id to last observed context id
mapping: { [componentId: number]: number } = {};
constructor(state: Object = {}) {
super();
this.observer = new Observer();
this.observer.notifyCB = this.__notifyComponents.bind(this);
this.state = this.observer.observe(state);
}
/**
* Instead of using trigger to emit an update event, we actually implement
* our own function to do that. The reason is that we need to be smarter than
* a simple trigger function: we need to wait for parent components to be
* done before doing children components. The reason is that if an update
* as an effect of destroying a children, we do not want to call the
* mapStoreToProps function of the child, nor rendering it.
*
* This method is not optimal if we have a bunch of asynchronous components:
* we wait sequentially for each component to be completed before updating the
* next. However, the only things that matters is that children are updated
* after their parents. So, this could be optimized by being smarter, and
* updating all widgets concurrently, except for parents/children.
*/
async __notifyComponents() {
const id = ++this.id;
const subs = this.subscriptions.update || [];
for (let i = 0, iLen = subs.length; i < iLen; i++) {
const sub = subs[i];
const shouldCallback = sub.owner ? sub.owner.__owl__.isMounted : true;
if (shouldCallback) {
await sub.callback.call(sub.owner, id);
}
}
}
}
/**
* The`useContext` hook is the normal way for a component to register themselve
* to context state changes. The `useContext` method returns the context state
*/
export function useContext(ctx: Context): any {
const component: Component<any, any> = Component._current;
const __owl__ = component.__owl__;
const id = __owl__.id;
const mapping = ctx.mapping;
if (id in mapping) {
return ctx.state;
}
mapping[id] = 0;
const renderFn = __owl__.render;
__owl__.render = function(comp, params) {
mapping[id] = ctx.id;
return renderFn(comp, params);
};
ctx.on("update", component, async contextId => {
if (mapping[id] < contextId) {
mapping[id] = contextId;
await component.render();
}
});
onWillUnmount(() => {
ctx.off("update", component);
delete mapping[id];
});
return ctx.state;
}
+3 -1
View File
@@ -12,6 +12,7 @@ import { Store } from "./store/store";
import * as _utils from "./utils";
import * as _tags from "./tags";
import * as _hooks from "./hooks";
import * as _context from "./Context";
import { Link } from "./router/Link";
import { RouteComponent } from "./router/RouteComponent";
import { Router } from "./router/Router";
@@ -19,13 +20,14 @@ import { Router } from "./router/Router";
export { Component } from "./component/component";
export { QWeb };
export const Context = _context.Context;
export const useState = _hooks.useState;
export const core = { EventBus, Observer };
export const router = { Router, RouteComponent, Link };
export const store = { Store, ConnectedComponent };
export const utils = _utils;
export const tags = _tags;
export const hooks = _hooks;
export const hooks = Object.assign({}, _hooks, { useContext: _context.useContext });
export const __info__ = {};
Object.defineProperty(__info__, "mode", {
+4 -42
View File
@@ -1,6 +1,5 @@
import { Env } from "../component/component";
import { EventBus } from "../core/event_bus";
import { Observer } from "../core/observer";
import { Context } from "../Context";
/**
* Owl Store
@@ -31,27 +30,15 @@ interface StoreConfig {
getters?: { [name: string]: Getter };
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
state: any;
export class Store extends Context {
actions: any;
mutations: any;
debug: boolean;
env: any;
observer: Observer;
getters: { [name: string]: (payload?) => any };
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
constructor(config: StoreConfig) {
super(config.state);
this.actions = config.actions;
this.env = config.env;
this.observer = new Observer();
this.observer.notifyCB = this.__notifyComponents.bind(this);
this.state = this.observer.observe(config.state || {});
this.getters = {};
if (config.getters) {
const firstArg = {
@@ -79,29 +66,4 @@ export class Store extends EventBus {
);
return result;
}
/**
* Instead of using trigger to emit an update event, we actually implement
* our own function to do that. The reason is that we need to be smarter than
* a simple trigger function: we need to wait for parent components to be
* done before doing children components. The reason is that if an update
* as an effect of destroying a children, we do not want to call the
* mapStoreToProps function of the child, nor rendering it.
*
* This method is not optimal if we have a bunch of asynchronous components:
* we wait sequentially for each component to be completed before updating the
* next. However, the only things that matters is that children are updated
* after their parents. So, this could be optimized by being smarter, and
* updating all widgets concurrently, except for parents/children.
*/
async __notifyComponents() {
const subs = this.subscriptions.update || [];
for (let i = 0, iLen = subs.length; i < iLen; i++) {
const sub = subs[i];
const shouldCallback = sub.owner ? sub.owner.__owl__.isMounted : true;
if (shouldCallback) {
await sub.callback.call(sub.owner);
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
import { makeTestEnv, makeTestFixture, nextTick } from "./helpers";
import { Component, Env } from "../src/component/component";
import { Context, useContext } from "../src/context";
import { xml } from "../src/tags";
import { useState } from "../src/hooks";
//------------------------------------------------------------------------------
// 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.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("Context", () => {
test("very simple use, with initial value", async () => {
const testContext = new Context({ value: 123 });
class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext);
}
const test = new Test(env);
await test.mount(fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
});
test("useContext hook is reactive, for one component", async () => {
const testContext = new Context({ value: 123 });
class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext);
}
const test = new Test(env);
await test.mount(fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
test.contextObj.value = 321;
await nextTick();
expect(fixture.innerHTML).toBe("<div>321</div>");
});
test("two components can subscribe to same context", async () => {
const testContext = new Context({ value: 123 });
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useContext(testContext);
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
testContext.state.value = 321;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
});
test("one components can subscribe twice to same context", async () => {
const testContext = new Context({ a: 1, b: 2 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></span>`;
contextObj1 = useContext(testContext);
contextObj2 = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /></div>`;
static components = { Child };
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
expect(steps).toEqual(['child']);
testContext.state.a = 3;
await nextTick();
expect(steps).toEqual(['child', 'child']);
});
test("parent and children subscribed to same context", async () => {
const testContext = new Context({ a: 123, b: 321 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`;
static components = { Child };
contextObj = useContext(testContext);
__render(fiber) {
steps.push("parent");
return super.__render(fiber);
}
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span>321</div>");
expect(steps).toEqual(["parent", "child"]);
parent.contextObj.a = 124;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>124</span>321</div>");
// we only want one render from the child here, not two
expect(steps).toEqual(["parent", "child", "parent", "child"]);
});
test("destroyed component is inactive", async () => {
const testContext = new Context({ a: 123 });
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext);
__render(fiber) {
steps.push("child");
return super.__render(fiber);
}
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: true });
__render(fiber) {
steps.push("parent");
return super.__render(fiber);
}
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span></div>");
expect(steps).toEqual(["parent", "child"]);
expect(testContext.subscriptions.update.length).toBe(1);
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["parent", "child", "parent"]);
// kind of whitebox...
// we make sure we do not have any pending subscriptions to the 'update'
// event
expect(testContext.subscriptions.update.length).toBe(0);
});
});
+65
View File
@@ -318,6 +318,66 @@ const HOOKS_CSS = `button {
font-size: 16px;
}`;
const CONTEXT_JS = `// In this example, we show how components can use the Context and 'useContext'
// hook to share information between them.
const { Component, Context } = owl;
const { useContext } = owl.hooks;
class ToolbarButton extends Component {
theme = useContext(this.env.themeContext);
get style () {
const theme = this.theme;
return \`background-color: \${theme.background}; color: \${theme.foreground}\`;
}
}
class Toolbar extends Component {
static components = { ToolbarButton };
}
// Main root component
class App extends Component {
static components = { Toolbar };
toggleTheme() {
const { background, foreground } = this.env.themeContext.state;
this.env.themeContext.state.background = foreground;
this.env.themeContext.state.foreground = background;
}
}
// Application setup
const themeContext = new Context({
background: '#000',
foreground: '#fff',
});
const env = {
qweb: new owl.QWeb(TEMPLATES),
themeContext: themeContext,
};
const app = new App(env);
app.mount(document.body);
`;
const CONTEXT_XML = `<templates>
<button t-name="ToolbarButton" t-att-style="style">
<t t-esc="props.name"/>
</button>
<div t-name="Toolbar">
<ToolbarButton name="'A'"/>
<ToolbarButton name="'B'"/>
<ToolbarButton name="'C'"/>
</div>
<div t-name="App">
<button t-on-click="toggleTheme">Toggle Mode</button>
<Toolbar/>
</div>
</templates>
`;
const TODO_APP_STORE = `// This example is an implementation of the TodoList application, from the
// www.todomvc.com project. This is a non trivial application with some
// interesting user interactions. It uses the local storage for persistence.
@@ -1655,6 +1715,11 @@ export const SAMPLES = [
xml: HOOKS_DEMO_XML,
css: HOOKS_CSS
},
{
description: "Context",
code: CONTEXT_JS,
xml: CONTEXT_XML,
},
{
description: "Todo List App (with store)",
code: TODO_APP_STORE,