Compare commits

..

3 Commits

Author SHA1 Message Date
Samuel Degueldre 0cd66c8518 [REL] v2.0.0-beta-7
# 2.0.0-beta-7

- fix: concurrency: do not render delayed fibers when cancelled
- imp: allow duplicate templates if and only if they are the same
2022-04-27 11:08:25 +02:00
Samuel Degueldre 5d9cff0331 [IMP] app: allow duplicate templates iff they are the same
Previously, we used an option to allow duplicate templates, if that
option was passed, we would always replace the existing template with
the new template, and if it wasn't, we would always throw an error even
if the template's content was the same.

Allowing to replace a template with a different one is problematic, as
it may or may not have already been compiled, which may cause various
problems. On the other hand, if the template is the same, there is no
point in throwing an error, as we can just silently ignore the second
addition.
2022-04-27 10:40:19 +02:00
Samuel Degueldre 41f1262eb7 [FIX] concurrency: do not render delayed fibers when cancelled
Previously, if a fiber was delayed because one of its ancestors was
rendering, and that fiber was not a root fiber, it would be rendered
after all its ancestors had finished rendering even if one of those
ancestor renderings cancelled it.

This commit fixes that by simply checking that a delayed fiber is still
its component node's current fiber before rendering it.
2022-04-19 12:07:35 +02:00
9 changed files with 169 additions and 33 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@odoo/owl", "name": "@odoo/owl",
"version": "2.0.0-beta-6", "version": "2.0.0-beta-7",
"description": "Odoo Web Library (OWL)", "description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js", "main": "dist/owl.cjs.js",
"browser": "dist/owl.iife.js", "browser": "dist/owl.iife.js",
+11 -9
View File
@@ -79,18 +79,20 @@ export class TemplateSet {
this.helpers = makeHelpers(this.getTemplate.bind(this)); this.helpers = makeHelpers(this.getTemplate.bind(this));
} }
addTemplate( addTemplate(name: string, template: string | Element) {
name: string, if (name in this.rawTemplates) {
template: string | Element, const rawTemplate = this.rawTemplates[name];
options: { allowDuplicate?: boolean } = {} const currentAsString = typeof rawTemplate === "string" ? rawTemplate : rawTemplate.outerHTML;
) { const newAsString = typeof template === "string" ? template : template.outerHTML;
if (name in this.rawTemplates && !options.allowDuplicate) { if (currentAsString === newAsString) {
throw new Error(`Template ${name} already defined`); return;
}
throw new Error(`Template ${name} already defined with different content`);
} }
this.rawTemplates[name] = template; this.rawTemplates[name] = template;
} }
addTemplates(xml: string | Document, options: { allowDuplicate?: boolean } = {}) { addTemplates(xml: string | Document) {
if (!xml) { if (!xml) {
// empty string // empty string
return; return;
@@ -98,7 +100,7 @@ export class TemplateSet {
xml = xml instanceof Document ? xml : parseXML(xml); xml = xml instanceof Document ? xml : parseXML(xml);
for (const template of xml.querySelectorAll("[t-name]")) { for (const template of xml.querySelectorAll("[t-name]")) {
const name = template.getAttribute("t-name")!; const name = template.getAttribute("t-name")!;
this.addTemplate(name, template, options); this.addTemplate(name, template);
} }
} }
+5
View File
@@ -42,6 +42,10 @@ export function makeRootFiber(node: ComponentNode): Fiber {
return fiber; return fiber;
} }
function throwOnRender() {
throw new Error("Attempted to render cancelled fiber");
}
/** /**
* @returns number of not-yet rendered fibers cancelled * @returns number of not-yet rendered fibers cancelled
*/ */
@@ -49,6 +53,7 @@ function cancelFibers(fibers: Fiber[]): number {
let result = 0; let result = 0;
for (let fiber of fibers) { for (let fiber of fibers) {
let node = fiber.node; let node = fiber.node;
fiber.render = throwOnRender;
if (node.status === STATUS.NEW) { if (node.status === STATUS.NEW) {
node.destroy(); node.destroy();
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export class Scheduler {
let renders = this.delayedRenders; let renders = this.delayedRenders;
this.delayedRenders = []; this.delayedRenders = [];
for (let f of renders) { for (let f of renders) {
if (f.root && f.node.status !== STATUS.DESTROYED) { if (f.root && f.node.status !== STATUS.DESTROYED && f.node.fiber === f) {
f.render(); f.render();
} }
} }
-9
View File
@@ -12,15 +12,6 @@ describe("error handling", () => {
expect(() => context.renderToString("invalidname")).toThrow("Missing template"); expect(() => context.renderToString("invalidname")).toThrow("Missing template");
}); });
test("cannot add twice the same template", () => {
const context = new TestContext();
context.addTemplate("test", `<t></t>`);
expect(() => context.addTemplate("test", "<div/>", { allowDuplicate: true })).not.toThrow(
"already defined"
);
expect(() => context.addTemplate("test", "<div/>")).toThrow("already defined");
});
test("addTemplates throw if parser error", () => { test("addTemplates throw if parser error", () => {
const context = new TestContext(); const context = new TestContext();
expect(() => { expect(() => {
+5 -4
View File
@@ -11,11 +11,12 @@ describe("basic validation", () => {
expect(() => context.getTemplate("invalidname")).toThrow("Missing template"); expect(() => context.getTemplate("invalidname")).toThrow("Missing template");
}); });
test("cannot add twice the same template", () => { test("cannot add a different template with the same name", () => {
const context = new TemplateSet(); const context = new TemplateSet();
expect(() => context.addTemplate("test", "<div/>", { allowDuplicate: true })).not.toThrow( context.addTemplate("test", `<t/>`);
"already defined" // Same template with the same name is fine
); expect(() => context.addTemplate("test", "<t/>")).not.toThrow();
// Different template with the same name crashes
expect(() => context.addTemplate("test", "<div/>")).toThrow("already defined"); expect(() => context.addTemplate("test", "<div/>")).toThrow("already defined");
}); });
@@ -1111,6 +1111,56 @@ exports[`delay willUpdateProps with rendering grandchild 4`] = `
}" }"
`; `;
exports[`delayed fiber does not get rendered if it was cancelled 1`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`A\`);
const b3 = component(\`B\`, {}, key + \`__1\`, node, ctx);
return multi([b2, b3]);
}
}"
`;
exports[`delayed fiber does not get rendered if it was cancelled 2`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`B\`);
const b3 = component(\`C\`, {}, key + \`__1\`, node, ctx);
return multi([b2, b3]);
}
}"
`;
exports[`delayed fiber does not get rendered if it was cancelled 3`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`C\`);
const b3 = component(\`D\`, {}, key + \`__1\`, node, ctx);
return multi([b2, b3]);
}
}"
`;
exports[`delayed fiber does not get rendered if it was cancelled 4`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`D\`);
}
}"
`;
exports[`delayed rendering, but then initial rendering is cancelled by yet another render 1`] = ` exports[`delayed rendering, but then initial rendering is cancelled by yet another render 1`] = `
"function anonymous(bdom, helpers "function anonymous(bdom, helpers
) { ) {
+83
View File
@@ -3696,6 +3696,89 @@ test("another scenario with delayed rendering", async () => {
]).toBeLogged(); ]).toBeLogged();
}); });
test("delayed fiber does not get rendered if it was cancelled", async () => {
class D extends Component {
static template = xml`D`;
setup() {
useLogLifecycle("", true);
}
}
class C extends Component {
static template = xml`C<D/>`;
static components = { D };
setup() {
useLogLifecycle("", true);
c = this;
}
}
let c: C;
class B extends Component {
static template = xml`B<C/>`;
static components = { C };
setup() {
useLogLifecycle("", true);
}
}
class A extends Component {
static template = xml`A<B/>`;
static components = { B };
setup() {
useLogLifecycle("", true);
}
}
const a = await mount(A, fixture);
expect(fixture.innerHTML).toBe("ABCD");
expect([
"A:setup",
"A:willRender",
"B:setup",
"A:rendered",
"B:willRender",
"C:setup",
"B:rendered",
"C:willRender",
"D:setup",
"C:rendered",
"D:willRender",
"D:rendered",
"D:mounted",
"C:mounted",
"B:mounted",
"A:mounted",
]).toBeLogged();
// Start a render in C
c!.render(true);
await nextMicroTick();
expect(["C:willRender", "C:rendered"]).toBeLogged();
// Start a render in A such that C is already rendered, but D will be delayed
// (because A is rendering) then cancelled (when the render from A reaches C)
a.render(true);
// Make sure the render can go to completion (Cancelled fibers will throw when rendered)
await nextTick();
expect([
"A:willRender",
"A:rendered",
"B:willRender",
"B:rendered",
"C:willRender",
"C:rendered",
"D:willRender",
"D:rendered",
"A:willPatch",
"B:willPatch",
"C:willPatch",
"D:willPatch",
"D:patched",
"C:patched",
"B:patched",
"A:patched",
]).toBeLogged();
});
test("destroyed component causes other soon to be destroyed component to rerender, weird stuff happens", async () => { test("destroyed component causes other soon to be destroyed component to rerender, weird stuff happens", async () => {
let def = makeDeferred(); let def = makeDeferred();
let c: any = null; let c: any = null;
+5 -1
View File
@@ -138,7 +138,7 @@ const steps: string[] = [];
export function logStep(step: string) { export function logStep(step: string) {
steps.push(step); steps.push(step);
} }
export function useLogLifecycle(key?: string) { export function useLogLifecycle(key?: string, skipAsyncHooks: boolean = false) {
const component = useComponent(); const component = useComponent();
let name = component.constructor.name; let name = component.constructor.name;
if (key) { if (key) {
@@ -147,20 +147,24 @@ export function useLogLifecycle(key?: string) {
logStep(`${name}:setup`); logStep(`${name}:setup`);
expect(name + ": " + status(component)).toBe(name + ": " + "new"); expect(name + ": " + status(component)).toBe(name + ": " + "new");
if (!skipAsyncHooks) {
onWillStart(() => { onWillStart(() => {
expect(name + ": " + status(component)).toBe(name + ": " + "new"); expect(name + ": " + status(component)).toBe(name + ": " + "new");
logStep(`${name}:willStart`); logStep(`${name}:willStart`);
}); });
}
onMounted(() => { onMounted(() => {
expect(name + ": " + status(component)).toBe(name + ": " + "mounted"); expect(name + ": " + status(component)).toBe(name + ": " + "mounted");
logStep(`${name}:mounted`); logStep(`${name}:mounted`);
}); });
if (!skipAsyncHooks) {
onWillUpdateProps(() => { onWillUpdateProps(() => {
expect(name + ": " + status(component)).toBe(name + ": " + "mounted"); expect(name + ": " + status(component)).toBe(name + ": " + "mounted");
logStep(`${name}:willUpdateProps`); logStep(`${name}:willUpdateProps`);
}); });
}
onWillRender(() => { onWillRender(() => {
logStep(`${name}:willRender`); logStep(`${name}:willRender`);