Compare commits

..

1 Commits

Author SHA1 Message Date
Romeo Fragomeli 26c7856d5d [REL] v2.4.1
# v2.4.1

 - [FIX] app: make subroots more robust
 - [IMP] devtools: show objects class name and add special extension cases
 - [IMP] devtools: allow expansion of empty class
 - [IMP] Bump support for Node 20+
2024-10-31 10:42:38 +01:00
17 changed files with 75 additions and 465 deletions
+17 -3
View File
@@ -2270,6 +2270,12 @@ function collectionsProxyHandler(target, callback, targetRawType) {
}
let currentNode = null;
function saveCurrent() {
let n = currentNode;
return () => {
currentNode = n;
};
}
function getCurrent() {
if (!currentNode) {
throw new OwlError("No active component (a hook function should only be called in 'setup')");
@@ -5557,7 +5563,7 @@ function compile(template, options = {}) {
}
// do not modify manually. This file is generated by the release script.
const version = "2.4.0";
const version = "2.4.1";
// -----------------------------------------------------------------------------
// Scheduler
@@ -5568,6 +5574,7 @@ class Scheduler {
this.frame = 0;
this.delayedRenders = [];
this.cancelledNodes = new Set();
this.processing = false;
this.requestAnimationFrame = Scheduler.requestAnimationFrame;
}
addFiber(fiber) {
@@ -5598,6 +5605,10 @@ class Scheduler {
}
}
processTasks() {
if (this.processing) {
return;
}
this.processing = true;
this.frame = 0;
for (let node of this.cancelledNodes) {
node._destroy();
@@ -5611,6 +5622,7 @@ class Scheduler {
this.tasks.delete(task);
}
}
this.processing = false;
}
processFiber(fiber) {
if (fiber.root !== fiber) {
@@ -5686,7 +5698,9 @@ class App extends TemplateSet {
if (config.env) {
this.env = config.env;
}
const restore = saveCurrent();
const node = this.makeNode(Root, props);
restore();
if (config.env) {
this.env = env;
}
@@ -6018,6 +6032,6 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2024-09-30T08:49:29.420Z';
__info__.hash = 'eb2b32a';
__info__.date = '2024-10-31T09:42:30.824Z';
__info__.hash = 'b8d09e5';
__info__.url = 'https://github.com/odoo/owl';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.4.0",
"version": "2.4.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.4.0",
"version": "2.4.1",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
+1 -1
View File
@@ -4,7 +4,7 @@ import {
interpolate,
INTERP_REGEXP,
replaceDynamicParts,
} from "../common/inline_expressions";
} from "./inline_expressions";
import {
AST,
ASTComment,
@@ -1,4 +1,4 @@
import { OwlError } from "./owl_error";
import { OwlError } from "../common/owl_error";
/**
* Owl QWeb Expression Parser
+37 -57
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 { handleError, nodeErrorHandlers } from "./error_handling";
import { Fiber, MountOptions, RootFiber } from "./fibers";
import { reactive, toRaw } from "./reactivity";
import { nodeErrorHandlers, handleError } from "./error_handling";
import { OwlError } from "../common/owl_error";
import { Fiber, RootFiber, MountOptions } from "./fibers";
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> | null;
node: ComponentNode<P, E>;
mount(target: HTMLElement | ShadowRoot, options?: MountOptions): Promise<Component<P, E>>;
destroy(): void;
}
@@ -74,10 +74,9 @@ export class App<
props: P;
env: E;
scheduler = new Scheduler();
subRoots: Set<Root<any, any>> = new Set();
root: Root<P, E> | null = null;
subRoots: Set<ComponentNode> = new Set();
root: ComponentNode<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);
@@ -102,8 +101,10 @@ export class App<
target: HTMLElement | ShadowRoot,
options?: MountOptions
): Promise<Component<P, E> & InstanceType<T>> {
this.root = this.createRoot(this.Root, { props: this.props });
return this.root.mount(target, options) as any;
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>(
@@ -111,38 +112,26 @@ 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;
const root: Root<Props, SubEnv> = {
node: null,
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,
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 } });
}
@@ -150,16 +139,11 @@ export class App<
return prom;
},
destroy: () => {
this.subRoots.delete(root);
if (root.node) {
root.node?.destroy();
this.scheduler.processTasks();
}
this.subRoots.delete(node);
node.destroy();
this.scheduler.processTasks();
},
};
this.subRoots.add(root);
return root;
}
makeNode(Component: ComponentConstructor, props: any): ComponentNode {
@@ -194,17 +178,13 @@ export class App<
}
destroy() {
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();
}
// if (this.root) {
// for (let subroot of this.subRoots) {
// subroot.destroy();
// }
// this.root.destroy();
this.scheduler.processTasks();
// }
apps.delete(this);
}
-1
View File
@@ -9,7 +9,6 @@ export type Props = { [key: string]: any };
interface StaticComponentProperties {
template: string;
dynamicContent?: { [spec: string]: string };
defaultProps?: any;
props?: Schema;
components?: { [componentName: string]: ComponentConstructor };
+2 -95
View File
@@ -6,9 +6,7 @@ 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, Markup } from "./utils";
import { xml } from "./template_set";
import { compileExpr } from "../common/inline_expressions";
import { batched, Callback } from "./utils";
let currentNode: ComponentNode | null = null;
@@ -126,102 +124,11 @@ 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 });
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.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
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" | "attach";
type Position = "first-child" | "last-child";
export interface MountOptions {
position?: Position;
-17
View File
@@ -136,20 +136,3 @@ 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;
}
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script.
export const version = "2.4.0";
export const version = "2.4.1";
@@ -94,6 +94,17 @@ 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/common/inline_expressions";
import { compileExpr, tokenize } from "../../src/compiler/inline_expressions";
describe("tokenizer", () => {
test("simple tokens", () => {
@@ -332,31 +332,6 @@ 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
) {
@@ -1,258 +0,0 @@
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!.node!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]);
});
test("props are validated whenever component is updated", async () => {
-1
View File
@@ -140,7 +140,6 @@ async function startRelease() {
}
// ---------------------------------------------------------------------------
log(`Step ${step++}/${STEPS}: Creating the release...`);
const relaseResult = await execCommand(`gh release create v${next} dist/*.js dist/*.zip ${draft} -F ${file}`);
if (relaseResult !== 0) {