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 * Owl QWeb Expression Parser
+1 -1
View File
@@ -4,7 +4,7 @@ import {
interpolate, interpolate,
INTERP_REGEXP, INTERP_REGEXP,
replaceDynamicParts, replaceDynamicParts,
} from "./inline_expressions"; } from "../common/inline_expressions";
import { import {
AST, AST,
ASTComment, ASTComment,
+57 -37
View File
@@ -1,14 +1,14 @@
import { OwlError } from "../common/owl_error";
import { version } from "../version"; import { version } from "../version";
import { Component, ComponentConstructor, Props } from "./component"; import { Component, ComponentConstructor, Props } from "./component";
import { ComponentNode, saveCurrent } from "./component_node"; import { ComponentNode, saveCurrent } from "./component_node";
import { nodeErrorHandlers, handleError } from "./error_handling"; import { handleError, nodeErrorHandlers } from "./error_handling";
import { OwlError } from "../common/owl_error"; import { Fiber, MountOptions, RootFiber } from "./fibers";
import { Fiber, RootFiber, MountOptions } from "./fibers"; import { reactive, toRaw } from "./reactivity";
import { Scheduler } from "./scheduler"; import { Scheduler } from "./scheduler";
import { validateProps } from "./template_helpers"; import { validateProps } from "./template_helpers";
import { TemplateSet, TemplateSetConfig } from "./template_set"; import { TemplateSet, TemplateSetConfig } from "./template_set";
import { validateTarget } from "./utils"; import { validateTarget } from "./utils";
import { toRaw, reactive } from "./reactivity";
// reimplement dev mode stuff see last change in 0f7a8289a6fb8387c3c1af41c6664b2a8448758f // reimplement dev mode stuff see last change in 0f7a8289a6fb8387c3c1af41c6664b2a8448758f
@@ -53,7 +53,7 @@ declare global {
} }
interface Root<P extends Props, E> { 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>>; mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
destroy(): void; destroy(): void;
} }
@@ -74,9 +74,10 @@ export class App<
props: P; props: P;
env: E; env: E;
scheduler = new Scheduler(); scheduler = new Scheduler();
subRoots: Set<ComponentNode> = new Set(); subRoots: Set<Root<any, any>> = new Set();
root: ComponentNode<P, E> | null = null; root: Root<P, E> | null = null;
warnIfNoStaticProps: boolean; warnIfNoStaticProps: boolean;
_lastRootEl: HTMLElement | ShadowRoot | null = null; // temporary ref to propagate to roots
constructor(Root: ComponentConstructor<P, E>, config: AppConfig<P, E> = {}) { constructor(Root: ComponentConstructor<P, E>, config: AppConfig<P, E> = {}) {
super(config); super(config);
@@ -101,10 +102,8 @@ export class App<
target: HTMLElement | ShadowRoot, target: HTMLElement | ShadowRoot,
options?: MountOptions options?: MountOptions
): Promise<Component<P, E> & InstanceType<T>> { ): Promise<Component<P, E> & InstanceType<T>> {
const root = this.createRoot(this.Root, { props: this.props }); this.root = this.createRoot(this.Root, { props: this.props });
this.root = root.node; return this.root.mount(target, options) as any;
this.subRoots.delete(root.node);
return root.mount(target, options) as any;
} }
createRoot<Props extends object, SubEnv = any>( createRoot<Props extends object, SubEnv = any>(
@@ -112,26 +111,38 @@ export class App<
config: RootConfig<Props, SubEnv> = {} config: RootConfig<Props, SubEnv> = {}
): Root<Props, SubEnv> { ): Root<Props, SubEnv> {
const props = config.props || ({} as Props); 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; const env = this.env;
if (config.env) { const root: Root<Props, SubEnv> = {
this.env = config.env as any; node: null,
}
const restore = saveCurrent();
const node = this.makeNode(Root, props);
restore();
if (config.env) {
this.env = env;
}
this.subRoots.add(node);
return {
node,
mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => { mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => {
App.validateTarget(target); 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) { if (this.dev) {
validateProps(Root, props, { __owl__: { app: this } }); validateProps(Root, props, { __owl__: { app: this } });
} }
@@ -139,11 +150,16 @@ export class App<
return prom; return prom;
}, },
destroy: () => { destroy: () => {
this.subRoots.delete(node); this.subRoots.delete(root);
node.destroy(); if (root.node) {
this.scheduler.processTasks(); root.node?.destroy();
this.scheduler.processTasks();
}
}, },
}; };
this.subRoots.add(root);
return root;
} }
makeNode(Component: ComponentConstructor, props: any): ComponentNode { makeNode(Component: ComponentConstructor, props: any): ComponentNode {
@@ -178,13 +194,17 @@ export class App<
} }
destroy() { destroy() {
if (this.root) { const roots = [...this.subRoots].reverse();
for (let subroot of this.subRoots) { for (let root of roots) {
subroot.destroy(); root.destroy();
}
this.root.destroy();
this.scheduler.processTasks();
} }
// if (this.root) {
// for (let subroot of this.subRoots) {
// subroot.destroy();
// }
// this.root.destroy();
this.scheduler.processTasks();
// }
apps.delete(this); apps.delete(this);
} }
+1
View File
@@ -9,6 +9,7 @@ export type Props = { [key: string]: any };
interface StaticComponentProperties { interface StaticComponentProperties {
template: string; template: string;
dynamicContent?: { [spec: string]: string };
defaultProps?: any; defaultProps?: any;
props?: Schema; props?: Schema;
components?: { [componentName: string]: ComponentConstructor }; 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 { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers";
import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity"; import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity";
import { STATUS } from "./status"; 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; 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); this.component = new C(props, env, this);
const ctx = Object.assign(Object.create(this.component), { this: this.component }); 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(); this.component.setup();
currentNode = null; 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) { mountComponent(target: any, options?: MountOptions) {
const fiber = new MountFiber(this, target, options); const fiber = new MountFiber(this, target, options);
this.app.scheduler.addFiber(fiber); 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 { export interface MountOptions {
position?: Position; position?: Position;
+17
View File
@@ -136,3 +136,20 @@ export function useExternalListener(
onMounted(() => target.addEventListener(eventName, boundHandler, eventParams)); onMounted(() => target.addEventListener(eventName, boundHandler, eventParams));
onWillUnmount(() => target.removeEventListener(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`] = ` exports[`subroot can mount subroot 1`] = `
"function anonymous(app, bdom, helpers "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", () => { describe("tokenizer", () => {
test("simple tokens", () => { 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`] = ` exports[`lifecycle hooks lifecycle semantics, part 4 1`] = `
"function anonymous(app, bdom, helpers "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 }); const app = new App(Parent, { test: true });
await app.mount(fixture); await app.mount(fixture);
expect(fixture.innerHTML).toBe("12"); 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 () => { test("props are validated whenever component is updated", async () => {