From b97166d831bb2c4e4d25e36865ef53da6f5caacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Fri, 22 Nov 2024 10:44:45 +0100 Subject: [PATCH] [FIX] runtime: properly handle error caused in mounted hook If a component onMounted hook causes an error, and an error handler tries to reinitiate a new correct render, the fiber may be recycled. But the scheduler removes it from its own set of tasks, so it will be ignored. We simply check for this condition before removing it from the tasks set. --- src/runtime/scheduler.ts | 9 ++- .../__snapshots__/error_handling.test.ts.snap | 44 +++++++++++ tests/components/error_handling.test.ts | 76 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/runtime/scheduler.ts b/src/runtime/scheduler.ts index 72079dfed..27c85ab7a 100644 --- a/src/runtime/scheduler.ts +++ b/src/runtime/scheduler.ts @@ -93,7 +93,14 @@ export class Scheduler { if (!hasError) { fiber.complete(); } - this.tasks.delete(fiber); + // at this point, the fiber should have been applied to the DOM, so we can + // remove it from the task list. If it is not the case, it means that there + // was an error and an error handler triggered a new rendering that recycled + // the fiber, so in that case, we actually want to keep the fiber around, + // otherwise it will just be ignored. + if (fiber.appliedToDom) { + this.tasks.delete(fiber); + } } } } diff --git a/tests/components/__snapshots__/error_handling.test.ts.snap b/tests/components/__snapshots__/error_handling.test.ts.snap index d674b9750..45f1bf43d 100644 --- a/tests/components/__snapshots__/error_handling.test.ts.snap +++ b/tests/components/__snapshots__/error_handling.test.ts.snap @@ -320,6 +320,50 @@ exports[`can catch errors can catch an error in a component render function 3`] }" `; +exports[`can catch errors can catch an error in onmounted 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + const comp1 = app.createComponent(null, false, false, false, []); + + return function template(ctx, node, key = \\"\\") { + let b2, b3; + b2 = text(\`Main\`); + if (ctx['state'].ok) { + const Comp1 = ctx['component']; + b3 = toggler(Comp1, comp1({}, (Comp1).name + key + \`__1\`, node, this, Comp1)); + } + return multi([b2, b3]); + } +}" +`; + +exports[`can catch errors can catch an error in onmounted 3`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
Error!!!
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`can catch errors can catch an error in onmounted 4`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
perfect
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`can catch errors can catch an error in the constructor call of a component render function 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/components/error_handling.test.ts b/tests/components/error_handling.test.ts index 9b45ea78c..703b29183 100644 --- a/tests/components/error_handling.test.ts +++ b/tests/components/error_handling.test.ts @@ -564,6 +564,82 @@ describe("can catch errors", () => { expect(mockConsoleWarn).toBeCalledTimes(0); }); + test("can catch an error in onmounted", async () => { + class ErrorComponent extends Component { + static template = xml`
Error!!!
`; + setup() { + useLogLifecycle(); + onMounted(() => { + throw new Error("error"); + }); + } + } + class PerfectComponent extends Component { + static template = xml`
perfect
`; + setup() { + useLogLifecycle(); + } + } + class Main extends Component { + static template = xml`Main`; + component: any; + state: any; + setup() { + this.state = useState({ ok: false }); + useLogLifecycle(); + this.component = ErrorComponent; + onError(() => { + this.component = PerfectComponent; + this.render(); + }); + } + } + + const app = await mount(Main, fixture); + expect(steps.splice(0)).toMatchInlineSnapshot(` + Array [ + "Main:setup", + "Main:willStart", + "Main:willRender", + "Main:rendered", + "Main:mounted", + ] + `); + expect(fixture.innerHTML).toBe("Main"); + (app as any).state.ok = true; + await nextTick(); + expect(fixture.innerHTML).toBe("Main
Error!!!
"); + expect(steps.splice(0)).toMatchInlineSnapshot(` + Array [ + "Main:willRender", + "ErrorComponent:setup", + "ErrorComponent:willStart", + "Main:rendered", + "ErrorComponent:willRender", + "ErrorComponent:rendered", + "Main:willPatch", + "ErrorComponent:mounted", + "Main:willRender", + "PerfectComponent:setup", + "PerfectComponent:willStart", + "Main:rendered", + ] + `); + await nextTick(); + expect(steps.splice(0)).toMatchInlineSnapshot(` + Array [ + "PerfectComponent:willRender", + "PerfectComponent:rendered", + "Main:willPatch", + "ErrorComponent:willUnmount", + "ErrorComponent:willDestroy", + "PerfectComponent:mounted", + "Main:patched", + ] + `); + expect(fixture.innerHTML).toBe("Main
perfect
"); + }); + test("calling a hook outside setup should crash", async () => { class Root extends Component { static template = xml``;