Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] owl: add basic support for sub roots #1641

Merged
merged 1 commit into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
ged-odoo marked this conversation as resolved.
Show resolved Hide resolved
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
Loading