Skip to content

Commit

Permalink
[IMP] owl: add basic support for sub roots
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ged-odoo committed Sep 27, 2024
1 parent 2a22328 commit 99a2224
Show file tree
Hide file tree
Showing 6 changed files with 381 additions and 9 deletions.
28 changes: 28 additions & 0 deletions doc/reference/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [API](#api)
- [Configuration](#configuration)
- [`mount` helper](#mount-helper)
- [Roots](#roots)
- [Loading templates](#loading-templates)

## Overview
Expand Down Expand Up @@ -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
Expand Down
63 changes: 54 additions & 9 deletions src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ export interface Env {
[key: string]: any;
}

export interface AppConfig<P, E> extends TemplateSetConfig {
name?: string;
export interface RootConfig<P, E> {
props?: P;
env?: E;
}

export interface AppConfig<P, E> extends TemplateSetConfig, RootConfig<P, E> {
name?: string;
test?: boolean;
warnIfNoStaticProps?: boolean;
}
Expand Down Expand Up @@ -49,6 +52,12 @@ declare global {
}
}

interface Root<P, E> {
node: ComponentNode<P, E>;
mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
destroy(): void;
}

window.__OWL_DEVTOOLS__ ||= { apps, Fiber, RootFiber, toRaw, reactive };

export class App<
Expand All @@ -65,6 +74,7 @@ export class App<
props: P;
env: E;
scheduler = new Scheduler();
subRoots: Set<ComponentNode> = new Set();
root: ComponentNode<P, E> | null = null;
warnIfNoStaticProps: boolean;

Expand All @@ -91,14 +101,46 @@ export class App<
target: HTMLElement | ShadowRoot,
options?: MountOptions
): Promise<Component<P, E> & InstanceType<T>> {
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<Props extends object, SubEnv = any>(
Root: ComponentConstructor<Props, E>,
config: RootConfig<Props, SubEnv> = {}
): Root<Props, SubEnv> {
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 {
Expand Down Expand Up @@ -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();
}
Expand Down
131 changes: 131 additions & 0 deletions tests/app/__snapshots__/sub_root.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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(\`<div>main app</div>\`);
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(\`<div>sub root</div>\`);
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(\`<div>main app</div>\`);
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(\`<div>sub root</div>\`);
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(\`<div>main app</div>\`);
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(\`<div>sub root</div>\`);
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(\`<div>main app</div>\`);
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(\`<div>sub root</div>\`);
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(\`<div>main app</div>\`);
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(\`<div>sub root</div>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
115 changes: 115 additions & 0 deletions tests/app/sub_root.test.ts
Original file line number Diff line number Diff line change
@@ -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`<div>main app</div>`;
}

class SubComponent extends Component {
static template = xml`<div>sub root</div>`;
}

describe("subroot", () => {
test("can mount subroot", async () => {
const app = new App(SomeComponent);
const comp = await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div>");
const subRoot = app.createRoot(SubComponent);
const subcomp = await subRoot.mount(fixture);
expect(fixture.innerHTML).toBe("<div>main app</div><div>sub root</div>");

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("<div>main app</div>");
const subRoot = app.createRoot(SubComponent);
const subcomp = await subRoot.mount(fixture.querySelector("div")!);
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");

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("<div>main app</div>");
const root = app.createRoot(SubComponent);
const subcomp = await root.mount(fixture.querySelector("div")!);
expect(fixture.innerHTML).toBe("<div>main app<div>sub root</div></div>");

root.destroy();
expect(fixture.innerHTML).toBe("<div>main app</div>");
expect(status(comp)).not.toBe("destroyed");
expect(status(subcomp)).toBe("destroyed");
});
});
Loading

0 comments on commit 99a2224

Please sign in to comment.