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 ) {