[FIX] components: improve error handling

Owl provides a way to manage errors occuring in component lifecycle
methods. However, before this commit, these errors were not always
logged or visible, which is very annoying in the common developer
workflow (doing something, checking it works, seeing no error but a
broken interface).

In this commit, we make sure errors are logged/throws in all cases:

- if an error occurs in a mounting operation => the promise is rejected
(which will log the error)
- if an error occurs after the mounting operation and is not handled by
any error handlers => the error will be logged (with console.error).
Also, in that case, this commit adds a warning to explain that owl
destroys the root component, which will help developers understanding
what happened.
This commit is contained in:
Géry Debongnie
2021-12-15 15:55:13 +01:00
committed by Aaron Bohy
parent fd295b3be3
commit 983b9f996d
4 changed files with 107 additions and 78 deletions
+7 -1
View File
@@ -81,10 +81,12 @@ export class App<T extends typeof Component = any> extends TemplateSet {
mountNode(node: ComponentNode, target: HTMLElement, options?: MountOptions) { mountNode(node: ComponentNode, target: HTMLElement, options?: MountOptions) {
const promise: any = new Promise((resolve, reject) => { const promise: any = new Promise((resolve, reject) => {
let isResolved = false;
// manually set a onMounted callback. // manually set a onMounted callback.
// that way, we are independant from the current node. // that way, we are independant from the current node.
node.mounted.push(() => { node.mounted.push(() => {
resolve(node.component); resolve(node.component);
isResolved = true;
}); });
// Manually add the last resort error handler on the node // Manually add the last resort error handler on the node
@@ -94,7 +96,11 @@ export class App<T extends typeof Component = any> extends TemplateSet {
nodeErrorHandlers.set(node, handlers); nodeErrorHandlers.set(node, handlers);
} }
handlers.unshift((e) => { handlers.unshift((e) => {
reject(e); if (isResolved) {
console.error(e);
} else {
reject(e);
}
throw e; throw e;
}); });
}); });
+4 -2
View File
@@ -57,9 +57,11 @@ export function handleError(params: ErrorParams) {
const handled = _handleError(node, error, true); const handled = _handleError(node, error, true);
if (!handled) { if (!handled) {
console.warn(`[Owl] Unhandled error. Destroying the root component`);
try { try {
node.app.destroy(); node.app.destroy();
} catch (e) {} } catch (e) {
console.error(e);
}
} }
return handled;
} }
@@ -968,3 +968,17 @@ exports[`errors and promises errors in mounted and in willUnmount 1`] = `
} }
}" }"
`; `;
exports[`errors and promises errors in rerender 1`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component, comment } = bdom;
let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['state'].a.b;
return block1([d1]);
}
}"
`;
+82 -75
View File
@@ -21,8 +21,23 @@ let fixture: HTMLElement;
snapshotEverything(); snapshotEverything();
let originalconsoleError = console.error;
let mockConsoleError: any;
let originalconsoleWarn = console.warn;
let mockConsoleWarn: any;
beforeEach(() => { beforeEach(() => {
fixture = makeTestFixture(); fixture = makeTestFixture();
mockConsoleError = jest.fn(() => {});
mockConsoleWarn = jest.fn(() => {});
console.error = mockConsoleError;
console.warn = mockConsoleWarn;
});
afterEach(() => {
console.error = originalconsoleError;
console.warn = originalconsoleWarn;
fixture = makeTestFixture();
}); });
describe("basics", () => { describe("basics", () => {
@@ -43,12 +58,11 @@ describe("basics", () => {
parent.render(); parent.render();
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe(""); expect(fixture.innerHTML).toBe("");
expect(mockConsoleError).toBeCalledTimes(1);
expect(mockConsoleWarn).toBeCalledTimes(1);
}); });
test("display a nice error if it cannot find component", async () => { test("display a nice error if it cannot find component", async () => {
const consoleError = console.error;
console.error = jest.fn();
class SomeComponent extends Component {} class SomeComponent extends Component {}
class Parent extends Component { class Parent extends Component {
static template = xml`<SomeMispelledComponent />`; static template = xml`<SomeMispelledComponent />`;
@@ -63,7 +77,8 @@ describe("basics", () => {
expect(error!).toBeDefined(); expect(error!).toBeDefined();
expect(error!.message).toBe('Cannot find the definition of component "SomeMispelledComponent"'); expect(error!.message).toBe('Cannot find the definition of component "SomeMispelledComponent"');
expect(console.error).toBeCalledTimes(0); expect(console.error).toBeCalledTimes(0);
console.error = consoleError; expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(1);
}); });
test("simple catchError", async () => { test("simple catchError", async () => {
@@ -92,13 +107,13 @@ describe("basics", () => {
} }
await mount(Parent, fixture); await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("<div>Error</div>"); expect(fixture.innerHTML).toBe("<div>Error</div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
}); });
describe("errors and promises", () => { describe("errors and promises", () => {
test("a rendering error will reject the mount promise", async () => { test("a rendering error will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore // we do not catch error in willPatch anymore
class App extends Component { class App extends Component {
static template = xml`<div><t t-esc="this.will.crash"/></div>`; static template = xml`<div><t t-esc="this.will.crash"/></div>`;
@@ -114,15 +129,11 @@ describe("errors and promises", () => {
const regexp = const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error!.message).toMatch(regexp); expect(error!.message).toMatch(regexp);
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleError).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("an error in mounted call will reject the mount promise", async () => { test("an error in mounted call will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class App extends Component { class App extends Component {
static template = xml`<div>abc</div>`; static template = xml`<div>abc</div>`;
setup() { setup() {
@@ -141,15 +152,11 @@ describe("errors and promises", () => {
expect(error!).toBeDefined(); expect(error!).toBeDefined();
expect(error!.message).toBe("boom"); expect(error!.message).toBe("boom");
expect(fixture.innerHTML).toBe(""); expect(fixture.innerHTML).toBe("");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(1);
console.error = consoleError;
}); });
test("an error in willPatch call will reject the render promise", async () => { test("an error in willPatch call will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Root extends Component { class Root extends Component {
static template = xml`<div><t t-esc="val"/></div>`; static template = xml`<div><t t-esc="val"/></div>`;
val = 3; val = 3;
@@ -168,14 +175,11 @@ describe("errors and promises", () => {
await nextTick(); await nextTick();
expect(error!).toBeDefined(); expect(error!).toBeDefined();
expect(error!.message).toBe("boom"); expect(error!.message).toBe("boom");
expect(console.error).toBeCalledTimes(0); expect(mockConsoleError).toBeCalledTimes(0);
console.error = consoleError; expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("an error in patched call will reject the render promise", async () => { test("an error in patched call will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Root extends Component { class Root extends Component {
static template = xml`<div><t t-esc="val"/></div>`; static template = xml`<div><t t-esc="val"/></div>`;
val = 3; val = 3;
@@ -194,13 +198,11 @@ describe("errors and promises", () => {
await nextTick(); await nextTick();
expect(error!).toBeDefined(); expect(error!).toBeDefined();
expect(error!.message).toBe("boom"); expect(error!.message).toBe("boom");
expect(console.error).toBeCalledTimes(0); expect(mockConsoleError).toBeCalledTimes(0);
console.error = consoleError; expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("a rendering error in a sub component will reject the mount promise", async () => { test("a rendering error in a sub component will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore // we do not catch error in willPatch anymore
class Child extends Component { class Child extends Component {
static template = xml`<div><t t-esc="this.will.crash"/></div>`; static template = xml`<div><t t-esc="this.will.crash"/></div>`;
@@ -220,15 +222,11 @@ describe("errors and promises", () => {
const regexp = const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error!.message).toMatch(regexp); expect(error!.message).toMatch(regexp);
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(1);
console.error = consoleError;
}); });
test("a rendering error will reject the render promise", async () => { test("a rendering error will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore
class Root extends Component { class Root extends Component {
static template = xml`<div><t t-if="flag" t-esc="this.will.crash"/></div>`; static template = xml`<div><t t-if="flag" t-esc="this.will.crash"/></div>`;
flag = false; flag = false;
@@ -247,9 +245,8 @@ describe("errors and promises", () => {
const regexp = const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error!.message).toMatch(regexp); expect(error!.message).toMatch(regexp);
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("a rendering error will reject the render promise (with sub components)", async () => { test("a rendering error will reject the render promise (with sub components)", async () => {
@@ -271,10 +268,12 @@ describe("errors and promises", () => {
const regexp = const regexp =
/Cannot read properties of undefined \(reading 'y'\)|Cannot read property 'y' of undefined/g; /Cannot read properties of undefined \(reading 'y'\)|Cannot read property 'y' of undefined/g;
expect(error!.message).toMatch(regexp); expect(error!.message).toMatch(regexp);
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(1);
}); });
test("errors in mounted and in willUnmount", async () => { test("errors in mounted and in willUnmount", async () => {
expect.assertions(2); expect.assertions(4);
class Example extends Component { class Example extends Component {
static template = xml`<div/>`; static template = xml`<div/>`;
val: any; val: any;
@@ -295,13 +294,31 @@ describe("errors and promises", () => {
} catch (e) { } catch (e) {
expect((e as Error).message).toBe("Error in mounted"); expect((e as Error).message).toBe("Error in mounted");
} }
// 1 additional error is logged because the destruction of the app causes
// the onWillUnmount hook to be called and to fail
expect(mockConsoleError).toBeCalledTimes(1);
expect(mockConsoleWarn).toBeCalledTimes(1);
});
test("errors in rerender", async () => {
class Example extends Component {
static template = xml`<div t-esc="state.a.b"/>`;
state: any = { a: { b: 1 } };
}
const root = await mount(Example, fixture);
expect(fixture.innerHTML).toBe("<div>1</div>");
root.state = "boom";
root.render();
await nextTick();
expect(fixture.innerHTML).toBe("");
expect(mockConsoleError).toBeCalledTimes(1);
expect(mockConsoleWarn).toBeCalledTimes(1);
}); });
}); });
describe("can catch errors", () => { describe("can catch errors", () => {
test("can catch an error in a component render function", async () => { test("can catch an error in a component render function", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component { class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="props.flag and state.this.will.crash"/></div>`; static template = xml`<div>hey<t t-esc="props.flag and state.this.will.crash"/></div>`;
} }
@@ -330,14 +347,11 @@ describe("can catch errors", () => {
app.state.flag = true; app.state.flag = true;
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the initial call of a component render function (parent mounted)", async () => { test("can catch an error in the initial call of a component render function (parent mounted)", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component { class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`; static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`;
} }
@@ -364,14 +378,11 @@ describe("can catch errors", () => {
} }
await mount(App, fixture); await mount(App, fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the initial call of a component render function (parent updated)", async () => { test("can catch an error in the initial call of a component render function (parent updated)", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component { class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`; static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`;
} }
@@ -399,15 +410,11 @@ describe("can catch errors", () => {
app.state.flag = true; app.state.flag = true;
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the constructor call of a component render function", async () => { test("can catch an error in the constructor call of a component render function", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component { class ErrorComponent extends Component {
static template = xml`<div>Some text</div>`; static template = xml`<div>Some text</div>`;
setup() { setup() {
@@ -433,15 +440,11 @@ describe("can catch errors", () => {
} }
await mount(App, fixture); await mount(App, fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the constructor call of a component render function 2", async () => { test("can catch an error in the constructor call of a component render function 2", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ClassicCompoent extends Component { class ClassicCompoent extends Component {
static template = xml`<div>classic</div>`; static template = xml`<div>classic</div>`;
} }
@@ -471,14 +474,11 @@ describe("can catch errors", () => {
} }
await mount(App, fixture); await mount(App, fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the willStart call", async () => { test("can catch an error in the willStart call", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component { class ErrorComponent extends Component {
static template = xml`<div>Some text</div>`; static template = xml`<div>Some text</div>`;
setup() { setup() {
@@ -507,15 +507,11 @@ describe("can catch errors", () => {
} }
await mount(App, fixture); await mount(App, fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error origination from a child's willStart function", async () => { test("can catch an error origination from a child's willStart function", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ClassicCompoent extends Component { class ClassicCompoent extends Component {
static template = xml`<div>classic</div>`; static template = xml`<div>classic</div>`;
} }
@@ -547,9 +543,8 @@ describe("can catch errors", () => {
} }
await mount(App, fixture); await mount(App, fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(mockConsoleError).toBeCalledTimes(0);
expect(console.error).toBeCalledTimes(0); expect(mockConsoleWarn).toBeCalledTimes(0);
console.error = consoleError;
}); });
test("can catch an error in the mounted call", async () => { test("can catch an error in the mounted call", async () => {
@@ -606,6 +601,8 @@ describe("can catch errors", () => {
"ErrorBoundary:mounted", "ErrorBoundary:mounted",
"Root:mounted", "Root:mounted",
]).toBeLogged(); ]).toBeLogged();
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("can catch an error in the mounted call (in root component)", async () => { test("can catch an error in the mounted call (in root component)", async () => {
@@ -649,6 +646,8 @@ describe("can catch errors", () => {
"Root:rendered", "Root:rendered",
"Root:mounted", "Root:mounted",
]).toBeLogged(); ]).toBeLogged();
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("can catch an error in the mounted call (in child of child)", async () => { test("can catch an error in the mounted call (in child of child)", async () => {
@@ -718,6 +717,8 @@ describe("can catch errors", () => {
"B:mounted", "B:mounted",
"A:mounted", "A:mounted",
]).toBeLogged(); ]).toBeLogged();
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("error in mounted on a component with a sibling (properly mounted)", async () => { test("error in mounted on a component with a sibling (properly mounted)", async () => {
@@ -787,6 +788,8 @@ describe("can catch errors", () => {
"OK:mounted", "OK:mounted",
"Root:mounted", "Root:mounted",
]).toBeLogged(); ]).toBeLogged();
expect(mockConsoleError).toBeCalledTimes(0);
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("can catch an error in the willPatch call", async () => { test("can catch an error in the willPatch call", async () => {
@@ -826,6 +829,7 @@ describe("can catch errors", () => {
await nextTick(); await nextTick();
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><span>def</span><div>Error handled</div></div>"); expect(fixture.innerHTML).toBe("<div><span>def</span><div>Error handled</div></div>");
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("catchError in catchError", async () => { test("catchError in catchError", async () => {
@@ -869,6 +873,7 @@ describe("can catch errors", () => {
await mount(Parent, fixture); await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("<div>Error</div>"); expect(fixture.innerHTML).toBe("<div>Error</div>");
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("onError in class inheritance is not called if no rethrown", async () => { test("onError in class inheritance is not called if no rethrown", async () => {
@@ -912,6 +917,7 @@ describe("can catch errors", () => {
expect(steps).toStrictEqual(["Concrete onError"]); expect(steps).toStrictEqual(["Concrete onError"]);
expect(fixture.innerHTML).toBe("<div>Concrete</div>"); expect(fixture.innerHTML).toBe("<div>Concrete</div>");
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
test("onError in class inheritance is called if rethrown", async () => { test("onError in class inheritance is called if rethrown", async () => {
@@ -956,5 +962,6 @@ describe("can catch errors", () => {
expect(steps).toStrictEqual(["Concrete onError", "Abstract onError"]); expect(steps).toStrictEqual(["Concrete onError", "Abstract onError"]);
expect(fixture.innerHTML).toBe("<div>Abstract</div>"); expect(fixture.innerHTML).toBe("<div>Abstract</div>");
expect(mockConsoleWarn).toBeCalledTimes(0);
}); });
}); });