diff --git a/packages/vue/src/hooks/lifecycle.ts b/packages/vue/src/hooks/lifecycle.ts index 0ddac52a5a4..ddf477dd402 100644 --- a/packages/vue/src/hooks/lifecycle.ts +++ b/packages/vue/src/hooks/lifecycle.ts @@ -1,6 +1,10 @@ import { printIonWarning } from "@ionic/core/components"; import type { ComponentInternalInstance } from "vue"; -import { getCurrentInstance } from "vue"; +import { + callWithAsyncErrorHandling, + ErrorCodes, + getCurrentInstance, +} from "vue"; import { LifecycleHooks } from "../utils"; @@ -29,7 +33,12 @@ const injectHook = ( return; } - return args ? hook(...args) : hook(); + return callWithAsyncErrorHandling( + hook, + component, + ErrorCodes.TRANSITION_HOOK, + args + ); }; hooks.push(wrappedHook); diff --git a/packages/vue/src/utils.ts b/packages/vue/src/utils.ts index d759bcdf0e7..55c3113e4b0 100644 --- a/packages/vue/src/utils.ts +++ b/packages/vue/src/utils.ts @@ -6,6 +6,7 @@ import { LIFECYCLE_WILL_LEAVE, } from "@ionic/core/components"; import type { Ref, ComponentPublicInstance } from "vue"; +import { callWithAsyncErrorHandling, ErrorCodes } from "vue"; type LIFECYCLE_EVENTS = | typeof LIFECYCLE_WILL_ENTER @@ -42,12 +43,20 @@ export const fireLifecycle = ( lifecycle: LIFECYCLE_EVENTS ) => { if (vueComponent?.[lifecycle]) { - vueComponent[lifecycle].bind(vueInstance?.value)(); + callWithAsyncErrorHandling( + vueComponent[lifecycle].bind(vueInstance?.value), + vueInstance?.value?.$ ?? null, + ErrorCodes.TRANSITION_HOOK + ); } const instance = vueInstance?.value as any; if (instance?.[lifecycle]) { - instance[lifecycle](); + callWithAsyncErrorHandling( + instance[lifecycle].bind(instance), + vueInstance.value.$, + ErrorCodes.TRANSITION_HOOK + ); } /** diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index 8d094cc9b18..0b6360b1eb8 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -25,6 +25,16 @@ const routes: Array = [ path: '/lifecycle-setup', component: () => import('@/views/LifecycleSetup.vue') }, + { + path: '/lifecycle-errors', + component: () => import('@/views/LifecycleErrorBoundary.vue'), + children: [ + { + path: '', + component: () => import('@/views/LifecycleErrorPage.vue') + } + ] + }, { path: '/overlays', component: () => import('@/views/Overlays.vue') diff --git a/packages/vue/test/base/src/views/LifecycleErrorBoundary.vue b/packages/vue/test/base/src/views/LifecycleErrorBoundary.vue new file mode 100644 index 00000000000..728f369854e --- /dev/null +++ b/packages/vue/test/base/src/views/LifecycleErrorBoundary.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/vue/test/base/src/views/LifecycleErrorPage.vue b/packages/vue/test/base/src/views/LifecycleErrorPage.vue new file mode 100644 index 00000000000..81720f3d7b3 --- /dev/null +++ b/packages/vue/test/base/src/views/LifecycleErrorPage.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/vue/test/base/tests/unit/lifecycle-errors.spec.ts b/packages/vue/test/base/tests/unit/lifecycle-errors.spec.ts new file mode 100644 index 00000000000..66c627588da --- /dev/null +++ b/packages/vue/test/base/tests/unit/lifecycle-errors.spec.ts @@ -0,0 +1,145 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createRouter, createWebHistory } from '@ionic/vue-router'; +import { + IonicVue, + IonPage, + IonRouterOutlet, + onIonViewDidEnter, + onIonViewDidLeave, + onIonViewWillEnter, + onIonViewWillLeave, +} from '@ionic/vue'; +import { defineComponent, h, onErrorCaptured, ref } from 'vue'; +import type { ComponentPublicInstance } from 'vue'; +import { waitForRouter } from './utils'; +import LifecycleErrorBoundary from '@/views/LifecycleErrorBoundary.vue'; +import LifecycleErrorPage from '@/views/LifecycleErrorPage.vue'; + +const lifecycles = [ + ['ionViewWillEnter', onIonViewWillEnter], + ['ionViewDidEnter', onIonViewDidEnter], + ['ionViewWillLeave', onIonViewWillLeave], + ['ionViewDidLeave', onIonViewDidLeave], +] as const; + +describe.each(['composition', 'options', 'methods'] as const)('%s lifecycle errors', (api) => { + describe.each(lifecycles)('%s', (lifecycle, registerHook) => { + it.each([ + ['synchronous', 'capture'], + ['asynchronous', 'capture'], + ['synchronous', 'app'], + ['asynchronous', 'app'], + ])('handles %s errors with the %s handler and completes navigation', async (timing, handler) => { + const error = new Error(`${api} ${lifecycle} ${timing}`); + const captured: unknown[] = []; + const reported: unknown[] = []; + const sources: (ComponentPublicInstance | null)[] = []; + const messages = ref([]); + let receiver: ComponentPublicInstance | undefined; + const fail = () => { throw error; }; + const hook = timing === 'asynchronous' ? async () => fail() : fail; + const optionHook = function (this: ComponentPublicInstance) { + receiver = this; + return hook(); + }; + const Page = defineComponent({ + name: 'LifecycleErrorPage', + ...(api === 'options' ? { [lifecycle]: optionHook } : {}), + ...(api === 'methods' ? { methods: { [lifecycle]: optionHook } } : {}), + setup() { + if (api === 'composition') { + registerHook(hook); + } + return { marker: 'error-page' }; + }, + render: () => h(IonPage, { 'data-pageid': 'error' }, () => 'Error page'), + }); + const OtherPage = defineComponent({ + render: () => h(IonPage, { 'data-pageid': 'other' }, () => 'Other page'), + }); + const Boundary = defineComponent({ + setup() { + onErrorCaptured((err, instance) => { + captured.push(err); + sources.push(instance); + if (handler === 'capture') { + messages.value.push((err as Error).message); + return false; + } + }); + return () => [h('p', { id: 'errors' }, messages.value.join(', ')), h(IonRouterOutlet)]; + }, + }); + const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: OtherPage }, + { path: '/error', component: Page }, + { path: '/other', component: OtherPage }, + ], + }); + await router.push('/'); + await router.isReady(); + const wrapper = mount(Boundary, { + global: { + plugins: [router, [IonicVue, { animated: false }]], + config: { + errorHandler(err) { + reported.push(err); + messages.value.push((err as Error).message); + }, + }, + }, + }); + try { + await router.push('/error'); + await waitForRouter(); + const page = wrapper.findComponent(Page); + expect(page.classes()).not.toContain('ion-page-invisible'); + await router.push('/other'); + await waitForRouter(); + expect(wrapper.find('[data-pageid="other"]').classes()).not.toContain('ion-page-invisible'); + expect(captured).toEqual([error]); + expect(reported).toEqual(handler === 'app' ? [error] : []); + expect(sources).toEqual([page.vm.$.proxy]); + expect(wrapper.find('#errors').text()).toBe(error.message); + if (api !== 'composition') { + expect(receiver).toBe(page.vm.$.proxy); + } + } finally { + wrapper.unmount(); + } + }); + }); +}); + +describe('Lifecycle error reproduction', () => { + it.each([ + ['composition', 'false'], + ['composition', 'true'], + ['options', 'false'], + ['options', 'true'], + ])('captures %s errors with async=%s', async (api, asynchronous) => { + const router = createRouter({ + history: createWebHistory(), + routes: [{ + path: '/lifecycle-errors', + component: LifecycleErrorBoundary, + children: [{ path: '', component: LifecycleErrorPage }], + }], + }); + await router.push(`/lifecycle-errors?api=${api}&async=${asynchronous}`); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { plugins: [router, [IonicVue, { animated: false }]] }, + }); + try { + await waitForRouter(); + expect(wrapper.find('#lifecycle-error').text()).toBe('Ionic lifecycle error'); + expect(wrapper.find('[data-pageid="lifecycle-error"]').classes()).not.toContain('ion-page-invisible'); + } finally { + wrapper.unmount(); + } + }); +});