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("");
+
+ 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("");
+
+ 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
) {