Debugging a Built App

Source maps, the warnings a production build drops, and how to reproduce a user's report against the real bundle.

Syntaxbuild: { sourcemap: 'hidden' }

A built app is minified, tree-shaken and stripped of development code. The bug the user hit is in that bundle, not in your dev server, so you need a way to read it.

Source maps

Vite's build.sourcemap is false by default, so stack traces point at one-letter names in a hashed file. Setting it to true emits .map files and a comment telling the browser to load them. 'hidden' emits the same maps but suppresses that comment: the browser ignores them and your error tracker, which you upload the maps to, does not. That is the usual choice for a public site.

What production leaves out

Development warnings are compiled away. There is no prop mutation warning, no missing-key warning, and app.config.warnHandler is ignored entirely. The reactivity debug options and onRenderTriggered are gone too. Errors still reach app.config.errorHandler and onErrorCaptured, but the info argument becomes a short code instead of a sentence; the production error code reference on vuejs.org translates it.

Two flags buy some of it back at a cost in bundle size: __VUE_PROD_DEVTOOLS__ lets DevTools attach, and __VUE_PROD_HYDRATION_MISMATCH_DETAILS__ (Vue 3.4 and later) restores the detailed mismatch warning. Both default to false and are set through Vite's define.

Reproducing the report

Build and serve locally before you theorise: npm run build then npm run preview runs the same bundle the user got. If it only fails when deployed, the difference is environment, not code.

Example

Example · vue
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'

const props = defineProps<{ release: string }>()

const failure = ref<string | null>(null)

onErrorCaptured((err, _instance, info) => {
  failure.value = err instanceof Error ? err.message : String(err)

  void fetch('/api/client-errors', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      release: props.release,
      source: info,
      message: failure.value,
      stack: err instanceof Error ? err.stack : null
    })
  })

  return false
})

function retry(): void {
  failure.value = null
}
</script>

<template>
  <p v-if="failure">
    Something went wrong: {{ failure }}
    <button type="button" @click="retry">Try again</button>
  </p>
  <slot v-else />
</template>

<style scoped>
p {
  color: #b00020;
}
</style>

When to use it

  • Turn an unreadable minified stack trace from a user report into real file names and line numbers.
  • Decide whether a bug is in your code or in the deployment by running the built bundle locally.
  • Get a detailed hydration mismatch message out of a server-rendered page that only breaks in production.

More examples

Build config for a debuggable release

define values are inserted as source text, which is why each one is a string containing 'true' or 'false'. Both flags default to false and both add code to the bundle, so turn the hydration one on while you are chasing a server-rendering mismatch and leave devtools off unless you specifically need to inspect the deployed app.

Example · ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  define: {
    // 3.4+; restores the detailed hydration mismatch warning
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true',
    __VUE_PROD_DEVTOOLS__: 'false'
  },
  build: {
    // maps are emitted but no comment points the browser at them
    sourcemap: 'hidden'
  }
})

Reproducing against the real bundle

preview serves the contents of dist over a static server, so you get the minified bundle, the production warning set and real file hashes. If the reported behaviour appears here, it is a code or build problem; if it does not, look at the deployed environment instead, starting with runtime configuration and caching.

Example · bash
# run the production bundle locally before changing any code
npm run build
npm run preview

Reporting every error from the app root

errorHandler is the last stop for errors thrown in renders, event handlers, lifecycle hooks, setup, watchers, directive hooks and transition hooks. In production info arrives as a short code rather than a phrase, so store it verbatim and look it up in the error code reference. Sending the release identifier with each report is what lets you match a stack trace to the right source maps.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

app.config.errorHandler = (err, _instance, info) => {
  console.error(err)

  void fetch('/api/client-errors', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      release: import.meta.env.VITE_RELEASE ?? 'local',
      source: info,
      message: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : null
    })
  })
}

app.mount('#app')

Discussion

  • Be the first to comment on this lesson.