Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/extract-fixed-nuxt-error-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

Add `FixedNuxtErrorBoundary`, extracted from duplicated per-project copies. Wraps Nuxt's error boundary with injectable `captureException`/`toastError`/`debug` props, distinguishes supported errors (setup/template) from unsupported ones (native event handlers, reported but not rendered), ignores interrupts-only Effect `CauseException` failures, and clears itself on route change.
213 changes: 213 additions & 0 deletions packages/vue-components/__tests__/FixedNuxtErrorBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { mount } from "@vue/test-utils"
import { CauseException } from "effect-app/client"
import * as Cause from "effect/Cause"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { defineComponent, h, nextTick, onErrorCaptured, ref } from "vue"
import { createMemoryHistory, createRouter, type Router } from "vue-router"
import FixedNuxtErrorBoundary from "../src/components/FixedNuxtErrorBoundary.vue"

/**
* Behavior contract of the shared error boundary (extracted from the two
* app-local copies — this pins the "no behavior change" claim of the dedup):
* - a supported error (e.g. thrown in setup) renders the error slot and
* emits "error"; it is NOT reported via captureException
* - an unsupported error (e.g. native event handler) is reported via
* captureException (+ toast when debug) and does NOT take over the page
* - an interrupts-only Effect CauseException is ignored entirely
* - a route change clears the error state
* - scheduleAfterReady defers handling (backend hydration path)
* - enabled=false registers no handler, so errors propagate to the parent
*/

const noopWarn = () => {}

// Throws in setup only while `active` is true — a supported error ("setup
// function"), and togglable so the route-change test can stop re-throwing.
const makeThrowingChild = (active: { value: boolean }, err: unknown) =>
defineComponent({
name: "ThrowingChild",
setup() {
if (active.value) throw err
return () => h("div", { id: "child-ok" }, "ok")
}
})

// Throws from a native click handler — an unsupported error info.
const ClickThrowChild = defineComponent({
name: "ClickThrowChild",
setup() {
return () =>
h("button", {
id: "boom",
onClick: () => {
throw new Error("click boom")
}
}, "boom")
}
})

const makeRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { render: () => null } },
{ path: "/other", component: { render: () => null } }
]
})

describe("FixedNuxtErrorBoundary", () => {
let captureException: ReturnType<typeof vi.fn>
let toastError: ReturnType<typeof vi.fn>
let router: Router
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
captureException = vi.fn()
toastError = vi.fn()
router = makeRouter()
warnSpy = vi.spyOn(console, "warn").mockImplementation(noopWarn)
})

afterEach(() => {
warnSpy.mockRestore()
})

// Mount through a host component that renders the boundary with its slots,
// mirroring real usage (Suspender wraps it). Mounting the boundary directly
// as the test root breaks error propagation into its own onErrorCaptured.
const mountBoundary = (
child: ReturnType<typeof defineComponent>,
props: Record<string, unknown> = {}
) => {
const emitted: Error[] = []
const Host = defineComponent({
name: "Host",
setup() {
return () =>
h(FixedNuxtErrorBoundary, {
captureException,
toastError,
debug: true,
onError: (e: Error) => emitted.push(e),
...props
}, {
default: () => h(child),
error: (params: { error: Error; clearError: () => void }) =>
h("div", { id: "error-slot" }, params.error.message)
})
}
})
const wrapper = mount(Host, { global: { plugins: [router] } })
return { wrapper, emitted }
}

it("renders the error slot and emits on a supported (setup) error, without reporting it", async () => {
const active = ref(true)
const { emitted, wrapper } = mountBoundary(makeThrowingChild(active, new Error("setup boom")))
await nextTick()

expect(wrapper.find("#error-slot").exists()).toBe(true)
expect(wrapper.find("#error-slot").text()).toBe("setup boom")
expect(emitted).toHaveLength(1)
expect(captureException).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalled()
})

it("reports an unsupported (native handler) error via captureException + toast, without taking over the page", async () => {
const { emitted, wrapper } = mountBoundary(ClickThrowChild)
await wrapper.find("#boom").trigger("click")
await nextTick()

expect(captureException).toHaveBeenCalledTimes(1)
expect(captureException.mock.calls[0][0]).toBeInstanceOf(Error)
expect(toastError).toHaveBeenCalledWith("An unexpected error has occurred: Error: click boom")
expect(wrapper.find("#error-slot").exists()).toBe(false)
expect(emitted).toHaveLength(0)
})

it("does not toast an unsupported error when debug is off", async () => {
const { wrapper } = mountBoundary(ClickThrowChild, { debug: false })
await wrapper.find("#boom").trigger("click")

expect(captureException).toHaveBeenCalledTimes(1)
expect(toastError).not.toHaveBeenCalled()
})

it("ignores an interrupts-only Effect CauseException", async () => {
const interrupted = new CauseException(Cause.interrupt(), "Interrupted")
const { emitted, wrapper } = mountBoundary(makeThrowingChild(ref(true), interrupted))
await nextTick()

expect(wrapper.find("#error-slot").exists()).toBe(false)
expect(emitted).toHaveLength(0)
expect(captureException).not.toHaveBeenCalled()
})

it("clears the error on route change", async () => {
const active = ref(true)
const { wrapper } = mountBoundary(makeThrowingChild(active, new Error("boom")))
await nextTick()
expect(wrapper.find("#error-slot").exists()).toBe(true)

active.value = false
await router.push("/other")
await nextTick()

expect(wrapper.find("#error-slot").exists()).toBe(false)
expect(wrapper.find("#child-ok").exists()).toBe(true)
})

it("defers handling through scheduleAfterReady when provided", async () => {
const queue: (() => void)[] = []
const { wrapper } = mountBoundary(makeThrowingChild(ref(true), new Error("deferred boom")), {
scheduleAfterReady: (fn: () => void) => queue.push(fn)
})
await nextTick()

expect(wrapper.find("#error-slot").exists()).toBe(false)
expect(queue).toHaveLength(1)

queue.forEach((fn) => fn())
await nextTick()
expect(wrapper.find("#error-slot").text()).toBe("deferred boom")
})

it("calls onHandled after emitting, before rendering the error slot", async () => {
const onHandled = vi.fn()
const { wrapper } = mountBoundary(makeThrowingChild(ref(true), new Error("hooked boom")), { onHandled })
await nextTick()

expect(onHandled).toHaveBeenCalledTimes(1)
expect(onHandled.mock.calls[0][0]).toBeInstanceOf(Error)
expect(onHandled.mock.calls[0][2]).toBe("setup function")
expect(wrapper.find("#error-slot").exists()).toBe(true)
})

it("registers no handler when enabled is false, letting the error propagate", async () => {
const parentCaught = vi.fn()
const Host = defineComponent({
name: "Host",
setup() {
onErrorCaptured((err) => {
parentCaught(err)
return false
})
return () =>
h(FixedNuxtErrorBoundary, {
captureException,
toastError,
debug: true,
enabled: false
}, {
default: () => h(makeThrowingChild(ref(true), new Error("escaped boom"))),
error: () => h("div", { id: "error-slot" })
})
}
})
const wrapper = mount(Host, { global: { plugins: [router] } })
await nextTick()

expect(parentCaught).toHaveBeenCalledTimes(1)
expect(wrapper.find("#error-slot").exists()).toBe(false)
})
})
1 change: 1 addition & 0 deletions packages/vue-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vue": "^3.5.35",
"vue-router": "^5.1.0",
"vuetify": "^4.0.8"
},
"devDependencies": {
Expand Down
104 changes: 104 additions & 0 deletions packages/vue-components/src/components/FixedNuxtErrorBoundary.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { CauseException } from "effect-app/client"
import * as Cause from "effect/Cause"
import { onErrorCaptured, shallowRef } from "vue"
import { useRouter } from "vue-router"

// withDefaults, NOT `props.enabled ?? true`: Vue's boolean casting resolves an
// ABSENT boolean prop to `false`, so the `??` fallback would never fire and an
// omitted `enabled` (the frontend case) would silently disable the boundary.
const props = withDefaults(
defineProps<{
captureException: (err: Error, hint?: { extra?: Record<string, unknown> }) => void
toastError: (message: string) => void
debug: boolean
/** register the boundary only when true (backend passes import.meta.client; frontend omits → true) */
enabled?: boolean
/** defer handling until app ready (backend: onNuxtReady while hydrating; frontend: omit → run now) */
scheduleAfterReady?: (fn: () => void) => void
/** extra side-effect after emit, before error.value is set (backend: nuxtApp vue:error hook) */
onHandled?: (err: Error, instance: unknown, info: string) => void
}>(),
{ enabled: true }
)

defineOptions({ name: "NuxtErrorBoundary", inheritAttrs: false })

const emit = defineEmits<{ error: [error: Error] }>()

defineSlots<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error(props: { error: Error; clearError: () => void }): any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default(): any
}>()

const error = shallowRef<Error | null>(null)
function clearError() {
error.value = null
}

// lol in production these are different...
const supportedErrors = ["https://vuejs.org/error-reference/#runtime-0", 0, "0", "setup function"]

function handleError(...args: Parameters<Parameters<typeof onErrorCaptured<Error>>[0]>) {
const [err, instance, info] = args
const fiberFailure = err instanceof CauseException ? err : null
// PRO: log that we hit the error boundary
console.warn(
"NuxtErrorBoundary caught error in " + info + ": " + (fiberFailure ? "CauseException" : "classic error"),
fiberFailure ? Cause.pretty(fiberFailure.originalCause) : err,
instance
)

// PRO: we don't handle native event handlers the same way we handle setup errors,
// because these errors should only get reported, not take over the page.
if (!supportedErrors.includes(info)) {
props.captureException(err, { extra: { info, instance } })
if (props.debug) {
props.toastError("An unexpected error has occurred: " + err.toString())
}
return
}

// PRO: don't render interruptions..
// e.g when we run useSuspenseQuery, and we navigate away before a query is finished, we get a CancelledError
// if we however render it here instead of the default slot, we will show the cancellation error of the previous page, instead of rendering the new page
if (fiberFailure && Cause.hasInterruptsOnly(fiberFailure.originalCause)) {
return
}

emit("error", err)
props.onHandled?.(err, instance, info)
error.value = err
}

if (props.enabled) {
onErrorCaptured((err, instance, info) => {
if (props.scheduleAfterReady) props.scheduleAfterReady(() => handleError(err, instance, info))
else handleError(err, instance, info)
return false
})
}

// PRO: fix error not clearing after route change: https://github.com/nuxt/nuxt/issues/15781#issuecomment-2320928163
const router = useRouter()
router.afterEach(() => {
clearError()
})

defineExpose({ error, clearError })
</script>

<template>
<slot
v-if="error"
v-bind="{ error, clearError }"
name="error"
/>

<slot
v-else
name="default"
/>
</template>
1 change: 1 addition & 0 deletions packages/vue-components/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as CommandButton } from "./CommandButton.vue"
export { default as Dialog } from "./Dialog.vue"
export { default as FixedNuxtErrorBoundary } from "./FixedNuxtErrorBoundary.vue"
export * from "./OmegaForm"
18 changes: 18 additions & 0 deletions packages/vue-components/stories/FixedNuxtErrorBoundary.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta as StoryMeta, StoryObj } from "@storybook/vue3"
import { vueRouter } from "storybook-vue3-router"
import Demo from "./FixedNuxtErrorBoundary/Demo.vue"

const meta: StoryMeta = {
title: "Components/FixedNuxtErrorBoundary"
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
decorators: [vueRouter()],
render: () => ({
components: { Demo },
template: "<Demo />"
})
}
Loading
Loading