Compare commits

...

2 Commits

Author SHA1 Message Date
Géry Debongnie c085eef441 experiment: add support for attached components 2024-10-31 07:39:24 +01:00
Géry Debongnie b4f84513a6 [REF] app: reorganize startup code
The goal of this refactoring is to delay the instantiation of the
component node until we are ready to mount the component. This is useful
so we know if a component will be attached or mounted (which means that
it needs to have a template, or not)
2024-10-29 13:49:04 +01:00
12 changed files with 458 additions and 55 deletions
@@ -1,4 +1,4 @@
import { OwlError } from "../common/owl_error";
import { OwlError } from "./owl_error";
/**
* Owl QWeb Expression Parser
+1 -1
View File
@@ -4,7 +4,7 @@ import {
interpolate,
INTERP_REGEXP,
replaceDynamicParts,
} from "./inline_expressions";
} from "../common/inline_expressions";
import {
AST,
ASTComment,
+57 -37
View File
@@ -1,14 +1,14 @@
import { OwlError } from "../common/owl_error";
import { version } from "../version";
import { Component, ComponentConstructor, Props } from "./component";
import { ComponentNode, saveCurrent } from "./component_node";
import { nodeErrorHandlers, handleError } from "./error_handling";
import { OwlError } from "../common/owl_error";
import { Fiber, RootFiber, MountOptions } from "./fibers";
import { handleError, nodeErrorHandlers } from "./error_handling";
import { Fiber, MountOptions, RootFiber } from "./fibers";
import { reactive, toRaw } from "./reactivity";
import { Scheduler } from "./scheduler";
import { validateProps } from "./template_helpers";
import { TemplateSet, TemplateSetConfig } from "./template_set";
import { validateTarget } from "./utils";
import { toRaw, reactive } from "./reactivity";
// reimplement dev mode stuff see last change in 0f7a8289a6fb8387c3c1af41c6664b2a8448758f
@@ -53,7 +53,7 @@ declare global {
}
interface Root<P extends Props, E> {
node: ComponentNode<P, E>;
node: ComponentNode<P, E> | null;
mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
destroy(): void;
}
@@ -74,9 +74,10 @@ export class App<
props: P;
env: E;
scheduler = new Scheduler();
subRoots: Set<ComponentNode> = new Set();
root: ComponentNode<P, E> | null = null;
subRoots: Set<Root<any, any>> = new Set();
root: Root<P, E> | null = null;
warnIfNoStaticProps: boolean;
_lastRootEl: HTMLElement | ShadowRoot | null = null; // temporary ref to propagate to roots
constructor(Root: ComponentConstructor<P, E>, config: AppConfig<P, E> = {}) {
super(config);
@@ -101,10 +102,8 @@ export class App<
target: HTMLElement | ShadowRoot,
options?: MountOptions
): Promise<Component<P, E> & InstanceType<T>> {
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;
this.root = this.createRoot(this.Root, { props: this.props });
return this.root.mount(target, options) as any;
}
createRoot<Props extends object, SubEnv = any>(
@@ -112,26 +111,38 @@ export class App<
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 restore = saveCurrent();
const node = this.makeNode(Root, props);
restore();
if (config.env) {
this.env = env;
}
this.subRoots.add(node);
return {
node,
const root: Root<Props, SubEnv> = {
node: null,
mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => {
App.validateTarget(target);
// 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
if (config.env) {
this.env = config.env as any;
}
const restore = saveCurrent();
if (options?.position === "attach") {
if (Root.template) {
throw new Error("Cannot attach a component with a template");
}
this._lastRootEl = target;
} else {
if (!Root.template) {
// no template => trigger an error
this.getTemplate("");
}
}
const node = this.makeNode(Root, props);
root.node = node;
this._lastRootEl = null;
restore();
if (config.env) {
this.env = env;
}
if (this.dev) {
validateProps(Root, props, { __owl__: { app: this } });
}
@@ -139,11 +150,16 @@ export class App<
return prom;
},
destroy: () => {
this.subRoots.delete(node);
node.destroy();
this.scheduler.processTasks();
this.subRoots.delete(root);
if (root.node) {
root.node?.destroy();
this.scheduler.processTasks();
}
},
};
this.subRoots.add(root);
return root;
}
makeNode(Component: ComponentConstructor, props: any): ComponentNode {
@@ -178,13 +194,17 @@ export class App<
}
destroy() {
if (this.root) {
for (let subroot of this.subRoots) {
subroot.destroy();
}
this.root.destroy();
this.scheduler.processTasks();
const roots = [...this.subRoots].reverse();
for (let root of roots) {
root.destroy();
}
// if (this.root) {
// for (let subroot of this.subRoots) {
// subroot.destroy();
// }
// this.root.destroy();
this.scheduler.processTasks();
// }
apps.delete(this);
}
+1
View File
@@ -9,6 +9,7 @@ export type Props = { [key: string]: any };
interface StaticComponentProperties {
template: string;
dynamicContent?: { [spec: string]: string };
defaultProps?: any;
props?: Schema;
components?: { [componentName: string]: ComponentConstructor };
+95 -2
View File
@@ -6,7 +6,9 @@ import { OwlError } from "../common/owl_error";
import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity";
import { STATUS } from "./status";
import { batched, Callback } from "./utils";
import { batched, Callback, Markup } from "./utils";
import { xml } from "./template_set";
import { compileExpr } from "../common/inline_expressions";
let currentNode: ComponentNode | null = null;
@@ -124,11 +126,102 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
}
this.component = new C(props, env, this);
const ctx = Object.assign(Object.create(this.component), { this: this.component });
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
if (C.template) {
this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
} else {
// component will be attached
this.renderFn = app.getTemplate(xml``).bind(this.component, ctx, this);
if (C.dynamicContent) {
this.prepareAttach(app._lastRootEl!, C.dynamicContent, ctx);
}
}
this.component.setup();
currentNode = null;
}
prepareAttach(
el: HTMLElement | ShadowRoot,
dynamicContent: { [spec: string]: string },
ctx: any
) {
const attrs: { selector: string; attr: string; fn: Function }[] = [];
const handlers: { selector: string; event: string; fn: any }[] = [];
const tOuts: { selector: string; fn: Function }[] = [];
for (let key in dynamicContent) {
const value = dynamicContent[key];
const parts = key.split(":");
if (parts[1].startsWith("t-att-")) {
const attr = parts[1].slice(6);
const fn = new Function("ctx", `return ${compileExpr(value)};`);
attrs.push({
selector: parts[0],
attr,
fn,
});
}
if (parts[1].startsWith("t-on-")) {
const event = parts[1].slice(5);
// const fn = new Function("ctx", "ev", `${compileExpr(value)}(ev);`);
const fn = (ev: any) => (this as any).component[value](ev);
handlers.push({
selector: parts[0],
event,
fn,
});
}
if (parts[1] === "t-out") {
const fn = new Function("ctx", `return ${compileExpr(value)};`);
tOuts.push({ selector: parts[0], fn });
}
}
const handleAttrs = () => {
for (let attr of attrs) {
const val = attr.fn.call(this.component, ctx);
// todo: cache the queryselector result?
const target = attr.selector === "root" ? el : (el.querySelector(attr.selector) as any);
if (target) {
target.setAttribute(attr.attr, val);
}
}
};
const handleEvents = () => {
for (let handler of handlers) {
// const val = attr.fn.call(this.component, ctx);
// todo: cache the queryselector result?
const target =
handler.selector === "root" ? el : (el.querySelector(handler.selector) as any);
if (target) {
target.addEventListener(handler.event, handler.fn);
}
}
};
const handleTOuts = () => {
for (let tOut of tOuts) {
const val = tOut.fn.call(this.component, ctx);
// todo: cache the queryselector result?
const target = tOut.selector === "root" ? el : (el.querySelector(tOut.selector) as any);
if (target) {
if (val instanceof Markup) {
target.innerHTML = val as any;
} else {
target.textContent = val;
}
}
}
};
if (attrs.length) {
this.mounted.push(handleAttrs);
this.patched.push(handleAttrs);
}
if (handlers.length) {
this.mounted.push(handleEvents);
}
if (tOuts.length) {
this.mounted.push(handleTOuts);
this.patched.push(handleTOuts);
}
}
mountComponent(target: any, options?: MountOptions) {
const fiber = new MountFiber(this, target, options);
this.app.scheduler.addFiber(fiber);
+1 -1
View File
@@ -207,7 +207,7 @@ export class RootFiber extends Fiber {
}
}
type Position = "first-child" | "last-child";
type Position = "first-child" | "last-child" | "attach";
export interface MountOptions {
position?: Position;
+17
View File
@@ -136,3 +136,20 @@ export function useExternalListener(
onMounted(() => target.addEventListener(eventName, boundHandler, eventParams));
onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams));
}
// -----------------------------------------------------------------------------
// useAttachedEl
// -----------------------------------------------------------------------------
/**
* The purpose of this hook is to allow attached components to get a reference to
* the element they have been attached on.
*/
export function useAttachedEl(): HTMLElement {
const node = getCurrent();
const el = node.app._lastRootEl as HTMLElement;
if (!el) {
throw new Error("useAttachedEl can only be called with component that are attached");
}
return el;
}
@@ -94,17 +94,6 @@ exports[`subroot can create a root in a setup function, then use a hook 1`] = `
}"
`;
exports[`subroot can create a root in a setup function, then use a hook 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`c\`);
}
}"
`;
exports[`subroot can mount subroot 1`] = `
"function anonymous(app, bdom, helpers
) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { compileExpr, tokenize } from "../../src/compiler/inline_expressions";
import { compileExpr, tokenize } from "../../src/common/inline_expressions";
describe("tokenizer", () => {
test("simple tokens", () => {
@@ -332,6 +332,31 @@ exports[`lifecycle hooks lifecycle semantics, part 3 1`] = `
}"
`;
exports[`lifecycle hooks lifecycle semantics, part 3 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`GrandChild\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`lifecycle hooks lifecycle semantics, part 3 4`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`lifecycle hooks lifecycle semantics, part 4 1`] = `
"function anonymous(app, bdom, helpers
) {
@@ -0,0 +1,258 @@
import { Component, markup, mount, useState, xml } from "../../src";
import { useAttachedEl } from "../../src/runtime/hooks";
import { makeTestFixture, nextTick, steps, useLogLifecycle } from "../helpers";
let fixture: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
});
describe("basics", () => {
test("can attach an empty component", async () => {
fixture.innerHTML = "<div>hello</div>";
class Test extends Component {
setup() {
useLogLifecycle();
}
}
await mount(Test, fixture, { position: "attach" });
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Test:setup",
"Test:willStart",
"Test:willRender",
"Test:rendered",
"Test:mounted",
]
`);
expect(fixture.innerHTML).toBe("<div>hello</div>");
});
test("attaching a component with a template throws", async () => {
fixture.innerHTML = "<div>hello</div>";
class Test extends Component {
static template = xml`hello`;
}
let error: Error | null = null;
try {
await mount(Test, fixture, { position: "attach" });
} catch (e: any) {
error = e;
}
expect(error!.message).toBe("Cannot attach a component with a template");
expect(fixture.innerHTML).toBe("<div>hello</div>");
});
test("can attach a component with simple dynamic content", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
class Test extends Component {
static dynamicContent = {
"p:t-att-a": "value",
};
value: string = "";
setup() {
this.value = "b";
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe('<div><p a="b">hello</p></div>');
});
test("useAttachedEl returns the attached element", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
let el: any = null;
class Test extends Component {
setup() {
el = useAttachedEl();
}
}
await mount(Test, fixture, { position: "attach" });
expect(el).toBe(fixture);
});
test("useAttachedEl throws if component is not attached", async () => {
class Test extends Component {
static template = xml`hello`;
setup() {
useAttachedEl();
}
}
let error: any = null;
try {
await mount(Test, fixture);
} catch (_e: any) {
error = _e;
}
expect(error.message).toBe("useAttachedEl can only be called with component that are attached");
});
test("multiple dynamic attribute", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
class Test extends Component {
static dynamicContent = {
"p:t-att-a": "value",
"p:t-att-b": "value + 'coucou'",
};
value: string = "";
setup() {
this.value = "b";
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe('<div><p a="b" b="bcoucou">hello</p></div>');
});
test("attrs can target root", async () => {
fixture.innerHTML = "<p>hello</p>";
class Test extends Component {
static dynamicContent = {
"root:t-att-a": "value",
};
value: string = "";
setup() {
this.value = "b";
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.outerHTML).toBe('<div a="b"><p>hello</p></div>');
});
test("dynamic attribute is updated on rerender", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
class Test extends Component {
static dynamicContent = {
"p:t-att-a": "state.value",
};
state: any;
setup() {
this.state = useState({ value: 1 });
}
}
const test = await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe('<div><p a="1">hello</p></div>');
test.state.value = 2;
await nextTick();
expect(fixture.innerHTML).toBe('<div><p a="2">hello</p></div>');
});
test("t-on-click, basic", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
let ev: Event | null = null;
class Test extends Component {
static dynamicContent = {
"p:t-att-a": "state.value",
"p:t-on-click": "onClick",
};
state: any;
setup() {
this.state = useState({ value: 1 });
}
onClick(_ev: any) {
ev = _ev;
this.state.value = 2;
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe('<div><p a="1">hello</p></div>');
fixture.querySelector("p")!.click();
expect(ev).toBeInstanceOf(Event);
await nextTick();
expect(fixture.innerHTML).toBe('<div><p a="2">hello</p></div>');
});
test("t-on-click, target root element", async () => {
fixture.innerHTML = "hello";
let click = false;
class Test extends Component {
static dynamicContent = {
"root:t-on-click": "onClick",
};
onClick() {
click = true;
}
}
await mount(Test, fixture, { position: "attach" });
fixture.click();
expect(click).toBe(true);
});
test("t-out, basic", async () => {
fixture.innerHTML = "<div><p>hello</p></div>";
class Test extends Component {
static dynamicContent = {
"p:t-out": "state.value",
};
state: any;
setup() {
this.state = useState({ value: 1 });
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe("<div><p>1</p></div>");
});
test("t-out, on root", async () => {
fixture.innerHTML = "hello";
class Test extends Component {
static dynamicContent = {
"root:t-out": "state.value",
};
state: any;
setup() {
this.state = useState({ value: 1 });
}
}
expect(fixture.outerHTML).toBe("<div>hello</div>");
await mount(Test, fixture, { position: "attach" });
expect(fixture.outerHTML).toBe("<div>1</div>");
});
test("t-out, with markup", async () => {
fixture.innerHTML = `<p class="p1">hello</p><p class="p2">hello</p>`;
class Test extends Component {
static dynamicContent = {
"p.p1:t-out": "value1",
"p.p2:t-out": "value2",
};
value1: any;
value2: any;
setup() {
this.value1 = "<div>value1</div>";
this.value2 = markup("<div>value2</div>");
}
}
await mount(Test, fixture, { position: "attach" });
expect(fixture.innerHTML).toBe(
`<p class="p1">&lt;div&gt;value1&lt;/div&gt;</p><p class="p2"><div>value2</div></p>`
);
});
});
+1 -1
View File
@@ -702,7 +702,7 @@ describe("props validation", () => {
const app = new App(Parent, { test: true });
await app.mount(fixture);
expect(fixture.innerHTML).toBe("12");
expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
expect(app.root!.node!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
});
test("props are validated whenever component is updated", async () => {