From 99a2224e8050f5e6b97ecc3c968b86965c75baa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 26 Sep 2024 15:23:15 +0200 Subject: [PATCH] [IMP] owl: add basic support for sub roots In this commit, we extend the owl App class to support multiple sub roots. This is useful for situations where we want to mount sub components in non-managed DOM. This is exactly what the Knowledge app is doing, with mounting views in an html editor. Currently, this requires some difficult and fragile hacks, and still, the result is that it is very easy to mix components from the main App and a SubApp. But Knowledge does not actually care about creating a sub app. It only needs the possibility to mount sub components in dynamic places. closes #1640 --- doc/reference/app.md | 28 ++++ src/runtime/app.ts | 63 +++++++-- tests/app/__snapshots__/sub_root.test.ts.snap | 131 ++++++++++++++++++ tests/app/sub_root.test.ts | 115 +++++++++++++++ .../__snapshots__/basics.test.ts.snap | 39 ++++++ .../props_validation.test.ts.snap | 14 ++ 6 files changed, 381 insertions(+), 9 deletions(-) create mode 100644 tests/app/__snapshots__/sub_root.test.ts.snap create mode 100644 tests/app/sub_root.test.ts diff --git a/doc/reference/app.md b/doc/reference/app.md index 14c7d04df..c168bc368 100644 --- a/doc/reference/app.md +++ b/doc/reference/app.md @@ -6,6 +6,7 @@ - [API](#api) - [Configuration](#configuration) - [`mount` helper](#mount-helper) +- [Roots](#roots) - [Loading templates](#loading-templates) ## Overview @@ -92,6 +93,33 @@ Most of the time, the `mount` helper is more convenient, but whenever one needs a reference to the actual Owl App, then using the `App` class directly is possible. +## Roots + +An application can have multiple roots. It is sometimes useful to instantiate +sub components in places that are not managed by Owl, such as an html editor +with dynamic content (the Knowledge application in Odoo). + +To create a root, one can use the `createRoot` method, which takes two arguments: + +- **`Component`**: a component class (Root component of the app) +- **`config (optional)`**: a config object that may contain a `props` object or a + `env` object. + +The `createRoot` method returns an object with a `mount` method (same API as +the `App.mount` method), and a `destroy` method. + +```js +const root = app.createRoot(MyComponent, { props: { someProps: true } }); +await root.mount(targetElement); + +// later +root.destroy(); +``` + +Note that, like with owl `App`, it is the responsibility of the code that created +the root to properly destroy it (before it has been removed from the DOM!). Owl +has no way of doing it itself. + ## Loading templates Most applications will need to load templates whenever they start. Here is diff --git a/src/runtime/app.ts b/src/runtime/app.ts index ace8fd1c3..189ed5b3b 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -16,10 +16,13 @@ export interface Env { [key: string]: any; } -export interface AppConfig extends TemplateSetConfig { - name?: string; +export interface RootConfig { props?: P; env?: E; +} + +export interface AppConfig extends TemplateSetConfig, RootConfig { + name?: string; test?: boolean; warnIfNoStaticProps?: boolean; } @@ -49,6 +52,12 @@ declare global { } } +interface Root { + node: ComponentNode; + mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise>; + destroy(): void; +} + window.__OWL_DEVTOOLS__ ||= { apps, Fiber, RootFiber, toRaw, reactive }; export class App< @@ -65,6 +74,7 @@ export class App< props: P; env: E; scheduler = new Scheduler(); + subRoots: Set = new Set(); root: ComponentNode | null = null; warnIfNoStaticProps: boolean; @@ -91,14 +101,46 @@ export class App< target: HTMLElement | ShadowRoot, options?: MountOptions ): Promise & InstanceType> { - App.validateTarget(target); - if (this.dev) { - validateProps(this.Root, this.props, { __owl__: { app: this } }); + const root = this.createRoot(this.Root, { props: this.props }); + this.root = root.node; + this.subRoots.delete(root.node); + return root.mount(target, options) as any; + } + + createRoot( + Root: ComponentConstructor, + config: RootConfig = {} + ): Root { + const props = config.props || ({} as Props); + // hack to make sure the sub root get the sub env if necessary. for owl 3, + // would be nice to rethink the initialization process to make sure that + // we can create a ComponentNode and give it explicitely the env, instead + // of looking it up in the app + const env = this.env; + if (config.env) { + this.env = config.env as any; + } + const node = this.makeNode(Root, props); + if (config.env) { + this.env = env; } - const node = this.makeNode(this.Root, this.props); - const prom = this.mountNode(node, target, options); - this.root = node; - return prom; + this.subRoots.add(node); + return { + node, + mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => { + App.validateTarget(target); + if (this.dev) { + validateProps(Root, props, { __owl__: { app: this } }); + } + const prom = this.mountNode(node, target, options); + return prom; + }, + destroy: () => { + this.subRoots.delete(node); + node.destroy(); + this.scheduler.processTasks(); + }, + }; } makeNode(Component: ComponentConstructor, props: any): ComponentNode { @@ -134,6 +176,9 @@ export class App< destroy() { if (this.root) { + for (let subroot of this.subRoots) { + subroot.destroy(); + } this.root.destroy(); this.scheduler.processTasks(); } diff --git a/tests/app/__snapshots__/sub_root.test.ts.snap b/tests/app/__snapshots__/sub_root.test.ts.snap new file mode 100644 index 000000000..7af7806b0 --- /dev/null +++ b/tests/app/__snapshots__/sub_root.test.ts.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`subroot by default, env is the same in sub root 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
main app
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot by default, env is the same in sub root 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
sub root
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot can mount subroot 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
main app
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot can mount subroot 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
sub root
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot can mount subroot inside own dom 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
main app
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot can mount subroot inside own dom 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
sub root
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot env can be specified for sub roots 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
main app
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot env can be specified for sub roots 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
sub root
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
main app
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`subroot subcomponents can be destroyed, and it properly cleanup the subroots 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
sub root
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; diff --git a/tests/app/sub_root.test.ts b/tests/app/sub_root.test.ts new file mode 100644 index 000000000..2f2146dc2 --- /dev/null +++ b/tests/app/sub_root.test.ts @@ -0,0 +1,115 @@ +import { App, Component, xml } from "../../src"; +import { status } from "../../src/runtime/status"; +import { makeTestFixture, snapshotEverything } from "../helpers"; + +let fixture: HTMLElement; + +snapshotEverything(); + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +class SomeComponent extends Component { + static template = xml`
main app
`; +} + +class SubComponent extends Component { + static template = xml`
sub root
`; +} + +describe("subroot", () => { + test("can mount subroot", async () => { + const app = new App(SomeComponent); + const comp = await app.mount(fixture); + expect(fixture.innerHTML).toBe("
main app
"); + const subRoot = app.createRoot(SubComponent); + const subcomp = await subRoot.mount(fixture); + expect(fixture.innerHTML).toBe("
main app
sub root
"); + + app.destroy(); + expect(fixture.innerHTML).toBe(""); + expect(status(comp)).toBe("destroyed"); + expect(status(subcomp)).toBe("destroyed"); + }); + + test("can mount subroot inside own dom", async () => { + const app = new App(SomeComponent); + const comp = await app.mount(fixture); + expect(fixture.innerHTML).toBe("
main app
"); + const subRoot = app.createRoot(SubComponent); + const subcomp = await subRoot.mount(fixture.querySelector("div")!); + expect(fixture.innerHTML).toBe("
main app
sub root
"); + + app.destroy(); + expect(fixture.innerHTML).toBe(""); + expect(status(comp)).toBe("destroyed"); + expect(status(subcomp)).toBe("destroyed"); + }); + + test("by default, env is the same in sub root", async () => { + let env, subenv; + class SC extends SomeComponent { + setup() { + env = this.env; + } + } + class Sub extends SubComponent { + setup() { + subenv = this.env; + } + } + + const app = new App(SC); + await app.mount(fixture); + const subRoot = app.createRoot(Sub); + await subRoot.mount(fixture); + + expect(env).toBeDefined(); + expect(subenv).toBeDefined(); + expect(env).toBe(subenv); + }); + + test("env can be specified for sub roots", async () => { + const env1 = { env1: true }; + const env2 = {}; + let someComponentEnv: any, subComponentEnv: any; + class SC extends SomeComponent { + setup() { + someComponentEnv = this.env; + } + } + class Sub extends SubComponent { + setup() { + subComponentEnv = this.env; + } + } + + const app = new App(SC, { env: env1 }); + await app.mount(fixture); + const subRoot = app.createRoot(Sub, { env: env2 }); + await subRoot.mount(fixture); + + // because env is different in app => it is given a sub object, frozen and all + // not sure it is a good idea, but it's the way owl 2 works. maybe we should + // avoid doing anything with the main env and let user code do it if they + // want. in that case, we can change the test here to assert that they are equal + expect(someComponentEnv).not.toBe(env1); + expect(someComponentEnv!.env1).toBe(true); + expect(subComponentEnv).toBe(env2); + }); + + test("subcomponents can be destroyed, and it properly cleanup the subroots", async () => { + const app = new App(SomeComponent); + const comp = await app.mount(fixture); + expect(fixture.innerHTML).toBe("
main app
"); + const root = app.createRoot(SubComponent); + const subcomp = await root.mount(fixture.querySelector("div")!); + expect(fixture.innerHTML).toBe("
main app
sub root
"); + + root.destroy(); + expect(fixture.innerHTML).toBe("
main app
"); + expect(status(comp)).not.toBe("destroyed"); + expect(status(subcomp)).toBe("destroyed"); + }); +}); diff --git a/tests/components/__snapshots__/basics.test.ts.snap b/tests/components/__snapshots__/basics.test.ts.snap index 699a8fb75..1c5405519 100644 --- a/tests/components/__snapshots__/basics.test.ts.snap +++ b/tests/components/__snapshots__/basics.test.ts.snap @@ -97,6 +97,19 @@ exports[`basics a component cannot be mounted in a detached node (even if node i }" `; +exports[`basics a component cannot be mounted in a detached node 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`basics a component inside a component 1`] = ` "function anonymous(app, bdom, helpers ) { @@ -261,6 +274,19 @@ exports[`basics can mount a simple component with props 1`] = ` }" `; +exports[`basics cannot mount on a documentFragment 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
content
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`basics child can be updated 1`] = ` "function anonymous(app, bdom, helpers ) { @@ -1002,6 +1028,19 @@ exports[`basics three level of components with collapsing root nodes 3`] = ` }" `; +exports[`basics throws if mounting on target=null 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`simple vnode\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`basics two child components 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/components/__snapshots__/props_validation.test.ts.snap b/tests/components/__snapshots__/props_validation.test.ts.snap index 56351a125..07cf4182b 100644 --- a/tests/components/__snapshots__/props_validation.test.ts.snap +++ b/tests/components/__snapshots__/props_validation.test.ts.snap @@ -924,6 +924,20 @@ exports[`props validation props: list of strings 1`] = ` }" `; +exports[`props validation validate props for root component 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let txt1 = ctx['message']; + return block1([txt1]); + } +}" +`; + exports[`props validation validate simple types 1`] = ` "function anonymous(app, bdom, helpers ) {