From 1c3c2e5c51f0cd8aa99af6ec38b1a5d3cf5cfdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 8 Oct 2019 09:00:06 +0200 Subject: [PATCH] [IMP] hooks/Context: add Context and useContext Closes #310 --- doc/context.md | 108 ++++++++++++++++++++++ doc/hooks.md | 12 ++- doc/readme.md | 3 + src/Context.ts | 86 ++++++++++++++++++ src/index.ts | 4 +- src/store/store.ts | 46 +--------- tests/Context.test.ts | 175 ++++++++++++++++++++++++++++++++++++ tools/playground/samples.js | 65 ++++++++++++++ 8 files changed, 452 insertions(+), 47 deletions(-) create mode 100644 doc/context.md create mode 100644 src/Context.ts create mode 100644 tests/Context.test.ts diff --git a/doc/context.md b/doc/context.md new file mode 100644 index 00000000..4e5e70fc --- /dev/null +++ b/doc/context.md @@ -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` +
+ + some simplified user interface + + + some more sopthisticated user interface + + `; + 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. diff --git a/doc/hooks.md b/doc/hooks.md index f9e6c164..ff31b0c7 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -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. \ No newline at end of file +the parent environment. And of course, the parent environment will not be +affected. diff --git a/doc/readme.md b/doc/readme.md index 1434a24c..3533e9a9 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -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) diff --git a/src/Context.ts b/src/Context.ts new file mode 100644 index 00000000..7bfe373d --- /dev/null +++ b/src/Context.ts @@ -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 = 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; +} diff --git a/src/index.ts b/src/index.ts index cc593ce9..90cdfb49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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", { diff --git a/src/store/store.ts b/src/store/store.ts index aee91784..1ac3c06e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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); - } - } - } } diff --git a/tests/Context.test.ts b/tests/Context.test.ts new file mode 100644 index 00000000..b3536da7 --- /dev/null +++ b/tests/Context.test.ts @@ -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 { + static template = xml`
`; + contextObj = useContext(testContext); + } + const test = new Test(env); + await test.mount(fixture); + expect(fixture.innerHTML).toBe("
123
"); + }); + + test("useContext hook is reactive, for one component", async () => { + const testContext = new Context({ value: 123 }); + + class Test extends Component { + static template = xml`
`; + contextObj = useContext(testContext); + } + const test = new Test(env); + await test.mount(fixture); + expect(fixture.innerHTML).toBe("
123
"); + test.contextObj.value = 321; + await nextTick(); + expect(fixture.innerHTML).toBe("
321
"); + }); + + test("two components can subscribe to same context", async () => { + const testContext = new Context({ value: 123 }); + + class Child extends Component { + static template = xml``; + contextObj = useContext(testContext); + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + } + const parent = new Parent(env); + await parent.mount(fixture); + expect(fixture.innerHTML).toBe("
123123
"); + testContext.state.value = 321; + await nextTick(); + expect(fixture.innerHTML).toBe("
321321
"); + }); + + 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 { + static template = xml``; + contextObj1 = useContext(testContext); + contextObj2 = useContext(testContext); + __render(fiber) { + steps.push("child"); + return super.__render(fiber); + } + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + } + const parent = new Parent(env); + await parent.mount(fixture); + expect(fixture.innerHTML).toBe("
12
"); + 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 { + static template = xml``; + contextObj = useContext(testContext); + __render(fiber) { + steps.push("child"); + return super.__render(fiber); + } + } + class Parent extends Component { + static template = xml`
`; + 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("
123321
"); + expect(steps).toEqual(["parent", "child"]); + + parent.contextObj.a = 124; + await nextTick(); + expect(fixture.innerHTML).toBe("
124321
"); + + // 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 { + static template = xml``; + contextObj = useContext(testContext); + __render(fiber) { + steps.push("child"); + return super.__render(fiber); + } + } + class Parent extends Component { + static template = xml`
`; + 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("
123
"); + expect(steps).toEqual(["parent", "child"]); + expect(testContext.subscriptions.update.length).toBe(1); + + parent.state.flag = false; + await nextTick(); + expect(fixture.innerHTML).toBe("
"); + 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); + }); +}); diff --git a/tools/playground/samples.js b/tools/playground/samples.js index cb041cdb..d777ecb8 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -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 = ` + + +
+ + + +
+ +
+ + +
+
+`; + 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,