From 1253b3922bcb30236f96c1cf2a09b32673932ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Sat, 24 Sep 2022 07:36:24 +0200 Subject: [PATCH] [IMP] hooks: add useRoots hook closes #1260 --- CHANGELOG.md | 7 +- doc/readme.md | 1 + doc/reference/hooks.md | 37 ++++++ src/runtime/blockdom/block_compiler.ts | 6 + src/runtime/blockdom/event_catcher.ts | 12 +- src/runtime/blockdom/html.ts | 6 + src/runtime/blockdom/index.ts | 2 + src/runtime/blockdom/list.ts | 6 + src/runtime/blockdom/multi.ts | 8 ++ src/runtime/blockdom/text.ts | 6 + src/runtime/blockdom/toggler.ts | 4 + src/runtime/component_node.ts | 6 + src/runtime/hooks.ts | 36 ++++++ src/runtime/index.ts | 10 +- .../__snapshots__/hooks.test.ts.snap | 66 +++++++++++ tests/components/hooks.test.ts | 110 +++++++++++++++++- 16 files changed, 314 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfef4401..0fcdbad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,7 +261,12 @@ This comes from the fact that Owl 2 supports fragments (arbitrary content). Migration: if one need a reference to the root htmlelement of a template, it is suggested to simply add a `ref` on it, and access the reference as needed. -Documentation: [Refs](doc/reference/refs.md) +Another way to get access to a root node or html element is the hook `useRoots` + +Documentation: +- [Refs](doc/reference/refs.md) +- [useRoots](doc/reference/hooks.md#useroots) + ### 10. style/class on components are now regular props diff --git a/doc/readme.md b/doc/readme.md index 36fe630f..98cac833 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -38,6 +38,7 @@ Other hooks: - [`useRef`](reference/hooks.md#useref): get an object representing a reference (`t-ref`) - [`useChildSubEnv`](reference/hooks.md#usesubenv-and-usechildsubenv): extend the current env with additional information (for child components) - [`useSubEnv`](reference/hooks.md#usesubenv-and-usechildsubenv): extend the current env with additional information (for current component and child components) +- [`useRoots`](reference/hooks.md#useroots): returns an object that provides access to all root nodes or htmlelements Utility/helpers: diff --git a/doc/reference/hooks.md b/doc/reference/hooks.md index e86369e9..098eb594 100644 --- a/doc/reference/hooks.md +++ b/doc/reference/hooks.md @@ -13,6 +13,7 @@ - [`useComponent`](#usecomponent) - [`useEnv`](#useenv) - [`useEffect`](#useeffect) + - [`useRoots`](#useroots) - [Example: Mouse Position](#example-mouse-position) ## Overview @@ -288,6 +289,42 @@ class SomeComponent extends Component { } ``` +### `useRoots` + +`useRoots` is an alternative way to get a reference to the root notes or elements +of a component. It may be useful in some cases where `useRef` cannot be applied, +such as a higher order component, or a component with only text as content. + +The return value of `useRoots` is an object with the following key/values: + +- `node`: getter that evaluates to the first node of the component content (or null) +- `elem`: getter that evaluates to the first HTMLElement of the component content (or null) +- `nodes`: iterator that returns all content nodes +- `elems`: iterator that returns all HTMLElement nodes + +```js +class Child extends Component { + static template = xml`

some content

`; +} + +class Parent extends Component { + static template = xml``; + static components = { Child }; + + setup() { + this.roots = useRoots(); + console.log(this.roots.elem); // null + onMounted(() => { + console.log(this.roots.elem); // log the `p` element from the child + + for (let elem of this.roots.elems) { + console.log(elem); // log the `p` element from the child + } + }); + } +} +``` + ## Example: mouse position Here is the classical example of a non trivial hook to track the mouse position. diff --git a/src/runtime/blockdom/block_compiler.ts b/src/runtime/blockdom/block_compiler.ts index 0730ee8a..9fc0bc57 100644 --- a/src/runtime/blockdom/block_compiler.ts +++ b/src/runtime/blockdom/block_compiler.ts @@ -517,6 +517,12 @@ function createBlockClass(template: HTMLElement, ctx: BlockCtx): BlockClass { return this.el!; } + *nodes() { + if (this.el) { + yield this.el; + } + } + moveBefore(other: Block | null, afterNode: Node | null) { const target = other ? other.el! : afterNode; nodeInsertBefore.call(this.parentEl, this.el!, target); diff --git a/src/runtime/blockdom/event_catcher.ts b/src/runtime/blockdom/event_catcher.ts index c3cc0b2b..66971754 100644 --- a/src/runtime/blockdom/event_catcher.ts +++ b/src/runtime/blockdom/event_catcher.ts @@ -12,7 +12,6 @@ export function createCatcher(eventsSpec: EventsSpec): Catcher { child: VNode; handlerData: any[]; handlerFns: any[] = []; - parentEl?: HTMLElement | undefined; afterNode: Text | null = null; @@ -44,13 +43,10 @@ export function createCatcher(eventsSpec: EventsSpec): Catcher { const self = this; handler[idx] = function (ev: any) { const target = ev.target; - let currentNode: any = self.child.firstNode(); - const afterNode = self.afterNode; - while (currentNode !== afterNode) { - if (currentNode.contains(target)) { + for (let node of self.nodes()) { + if (node.contains(target)) { return origFn.call(this, ev); } - currentNode = currentNode.nextSibling; } }; } @@ -90,6 +86,10 @@ export function createCatcher(eventsSpec: EventsSpec): Catcher { return this.child.firstNode(); } + nodes(): Generator { + return this.child.nodes(); + } + toString(): string { return this.child.toString(); } diff --git a/src/runtime/blockdom/html.ts b/src/runtime/blockdom/html.ts index 0c01eccf..d0a09846 100644 --- a/src/runtime/blockdom/html.ts +++ b/src/runtime/blockdom/html.ts @@ -78,6 +78,12 @@ class VHtml { return this.content[0]!; } + *nodes() { + for (let elem of this.content) { + yield elem; + } + } + toString() { return this.html; } diff --git a/src/runtime/blockdom/index.ts b/src/runtime/blockdom/index.ts index 0bc65806..a4701702 100644 --- a/src/runtime/blockdom/index.ts +++ b/src/runtime/blockdom/index.ts @@ -16,6 +16,8 @@ export interface VNode { remove(): void; firstNode(): Node | undefined; + nodes(): Generator; + el?: undefined | HTMLElement | Text; parentEl?: undefined | HTMLElement; isOnlyChild?: boolean | undefined; diff --git a/src/runtime/blockdom/list.ts b/src/runtime/blockdom/list.ts index 08fd5749..2a4b50cc 100644 --- a/src/runtime/blockdom/list.ts +++ b/src/runtime/blockdom/list.ts @@ -221,6 +221,12 @@ class VList { return child ? child.firstNode() : undefined; } + *nodes() { + for (let child of this.children) { + yield* child.nodes(); + } + } + toString(): string { return this.children.map((c) => c!.toString()).join(""); } diff --git a/src/runtime/blockdom/multi.ts b/src/runtime/blockdom/multi.ts index c9278208..5f54c766 100644 --- a/src/runtime/blockdom/multi.ts +++ b/src/runtime/blockdom/multi.ts @@ -124,6 +124,14 @@ export class VMulti { return child ? child.firstNode() : this.anchors![0]; } + *nodes() { + for (let child of this.children) { + if (child) { + yield* child.nodes(); + } + } + } + toString(): string { return this.children.map((c) => (c ? c!.toString() : "")).join(""); } diff --git a/src/runtime/blockdom/text.ts b/src/runtime/blockdom/text.ts index 25da7baf..dddd59a9 100644 --- a/src/runtime/blockdom/text.ts +++ b/src/runtime/blockdom/text.ts @@ -38,6 +38,12 @@ abstract class VSimpleNode { return this.el!; } + *nodes(): Generator { + if (this.el) { + yield this.el; + } + } + toString() { return this.text; } diff --git a/src/runtime/blockdom/toggler.ts b/src/runtime/blockdom/toggler.ts index d31c84ad..34778fb6 100644 --- a/src/runtime/blockdom/toggler.ts +++ b/src/runtime/blockdom/toggler.ts @@ -55,6 +55,10 @@ class VToggler { return this.child.firstNode(); } + nodes() { + return this.child.nodes(); + } + toString(): string { return this.child.toString(); } diff --git a/src/runtime/component_node.ts b/src/runtime/component_node.ts index e6636001..7184c151 100644 --- a/src/runtime/component_node.ts +++ b/src/runtime/component_node.ts @@ -296,6 +296,12 @@ export class ComponentNode

implements VNode { + if (this.bdom) { + yield* this.bdom.nodes(); + } + } + mount(parent: HTMLElement, anchor: ChildNode) { const bdom = this.fiber!.bdom!; this.bdom = bdom; diff --git a/src/runtime/hooks.ts b/src/runtime/hooks.ts index 9d3b54bd..12035cd8 100644 --- a/src/runtime/hooks.ts +++ b/src/runtime/hooks.ts @@ -127,3 +127,39 @@ export function useExternalListener( onMounted(() => target.addEventListener(eventName, boundHandler, eventParams)); onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams)); } + +// ----------------------------------------------------------------------------- +// useRoots +// ----------------------------------------------------------------------------- +interface DomRangeObj { + node: Node | null; + elem: HTMLElement | null; + elems: Iterable; + nodes: Iterable; +} + +export function useRoots(): DomRangeObj { + const cnode = getCurrent(); + + function* _elems(): Generator { + for (let node of cnode.nodes()) { + if (node.nodeType === 1) { + yield node as HTMLElement; + } + } + } + return { + get node() { + return cnode.nodes().next().value || null; + }, + get elem() { + return _elems().next().value || null; + }, + get nodes() { + return cnode.nodes(); + }, + get elems() { + return _elems(); + }, + }; +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 75f8e3b8..ebf492ea 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -40,7 +40,15 @@ export type { ComponentConstructor } from "./component"; export { useComponent, useState } from "./component_node"; export { status } from "./status"; export { reactive, markRaw, toRaw } from "./reactivity"; -export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks"; +export { + useEffect, + useEnv, + useExternalListener, + useRef, + useChildSubEnv, + useSubEnv, + useRoots, +} from "./hooks"; export { EventBus, whenReady, loadFile, markup } from "./utils"; export { onWillStart, diff --git a/tests/components/__snapshots__/hooks.test.ts.snap b/tests/components/__snapshots__/hooks.test.ts.snap index 102c1afd..df6fa9e4 100644 --- a/tests/components/__snapshots__/hooks.test.ts.snap +++ b/tests/components/__snapshots__/hooks.test.ts.snap @@ -405,3 +405,69 @@ exports[`hooks useSubEnv supports arbitrary descriptor 2`] = ` } }" `; + +exports[`useRoots hook return a list of nodes and elems 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`hey\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`useRoots hook return a list of nodes and elems 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block3 = createBlock(\`hey\`); + let block5 = createBlock(\`

bla

\`); + + return function template(ctx, node, key = \\"\\") { + const b2 = text(\` some text \`); + const b3 = block3(); + const b4 = text(\` coucou \`); + const b5 = block5(); + return multi([b2, b3, b4, b5]); + } +}" +`; + +exports[`useRoots hook useRoots and lifecycle 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`hey\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`useRoots hook useRoots is up to date 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block2 = createBlock(\`hey\`); + let block5 = createBlock(\`

paragraph

\`); + + return function template(ctx, node, key = \\"\\") { + let b2,b3; + if (ctx['state'].flag) { + b2 = block2(); + } else { + const b4 = text(\` coucou \`); + const b5 = block5(); + b3 = multi([b4, b5]); + } + return multi([b2, b3]); + } +}" +`; diff --git a/tests/components/hooks.test.ts b/tests/components/hooks.test.ts index 7b72e265..902fbfb8 100644 --- a/tests/components/hooks.test.ts +++ b/tests/components/hooks.test.ts @@ -19,6 +19,7 @@ import { xml, OwlError, } from "../../src/index"; +import { useRoots } from "../../src/runtime/hooks"; import { elem, logStep, @@ -518,7 +519,7 @@ describe("hooks", () => { }); test("dependencies prevent effects from rerunning when unchanged", async () => { - let steps = []; + let steps: string[] = []; class MyComponent extends Component { state = useState({ a: 0, @@ -673,3 +674,110 @@ describe("hooks", () => { }); }); }); + +describe("useRoots hook", () => { + test("return a list of nodes and elems", async () => { + class MyComponent extends Component { + static template = xml`hey`; + roots: any; + + setup() { + this.roots = useRoots(); + } + } + + const comp = await mount(MyComponent, fixture); + + expect(comp.roots.node).toBeInstanceOf(HTMLSpanElement); + expect(comp.roots.elem).toBeInstanceOf(HTMLSpanElement); + expect([...comp.roots.nodes].map((n: any) => n.tagName)).toEqual(["SPAN"]); + expect([...comp.roots.elems].map((n: any) => n.tagName)).toEqual(["SPAN"]); + }); + + test("return a list of nodes and elems", async () => { + class MyComponent extends Component { + static template = xml` + some text + hey + coucou +

bla

`; + roots: any; + + setup() { + this.roots = useRoots(); + } + } + + const comp = await mount(MyComponent, fixture); + + expect(comp.roots.node).toBeInstanceOf(Text); + expect(comp.roots.elem).toBeInstanceOf(HTMLSpanElement); + + expect([...comp.roots.elems].map((n: any) => n.tagName)).toEqual(["SPAN", "P"]); + expect([...comp.roots.nodes].map((n: any) => n.nodeType)).toEqual([3, 1, 3, 1]); + }); + + test("useRoots and lifecycle", async () => { + expect.assertions(13); + class MyComponent extends Component { + static template = xml`hey`; + roots: any; + + setup() { + this.roots = useRoots(); + expect(this.roots.elem).toBe(null); + expect(this.roots.node).toBe(null); + expect([...this.roots.nodes]).toEqual([]); + expect([...this.roots.elems]).toEqual([]); + onWillStart(() => { + expect(this.roots.elem).toBe(null); + expect(this.roots.node).toBe(null); + expect([...this.roots.nodes]).toEqual([]); + expect([...this.roots.elems]).toEqual([]); + }); + onMounted(() => { + expect(this.roots.elem).toBeInstanceOf(HTMLSpanElement); + expect(this.roots.node).toBeInstanceOf(HTMLSpanElement); + expect([...this.roots.elems].map((n: any) => n.tagName)).toEqual(["SPAN"]); + expect([...this.roots.nodes].map((n: any) => n.nodeType)).toEqual([1]); + }); + } + } + + const comp = await mount(MyComponent, fixture); + comp.__owl__.app.destroy(); + }); + + test("useRoots is up to date", async () => { + class MyComponent extends Component { + static template = xml` + + hey + + + coucou +

paragraph

+
`; + roots: any; + state: any; + + setup() { + this.roots = useRoots(); + this.state = useState({ flag: true }); + onMounted(() => { + expect(this.roots.elem).toBeInstanceOf(HTMLSpanElement); + expect(this.roots.node).toBeInstanceOf(HTMLSpanElement); + expect([...this.roots.elems].map((n: any) => n.tagName)).toEqual(["SPAN"]); + expect([...this.roots.nodes].map((n: any) => n.nodeType)).toEqual([1]); + }); + } + } + + const comp = await mount(MyComponent, fixture); + comp.state.flag = false; + await nextTick(); + expect(comp.roots.node.nodeType).toBe(3); // text node + expect(comp.roots.node.textContent).toBe(" coucou "); // text node + expect(comp.roots.elem).toBeInstanceOf(HTMLParagraphElement); + }); +});