Search code examples
javascriptvue.jsminify

Minification of Vuejs App causes browser to crash


I want to make sure that the user of this Vue application only enters correctly formatted currency values in the input boxes. To do this, I added a Regex test that makes sure the user can only type in correctly formatted values. The Regex works as expected, according to a bunch of different Regex testers I've found online for JavaScript. Everything works correctly when I'm running the app in a development environment.

However, when I use npm run build and use the minified version of the app, typing a non-number into the input box causes the web browser to crash. Windows Task Manager shows the CPU usage of that particular tab spike quite sharply. When using the Chrome debugger, it looks like any non-numeric character causes the app to enter an infinite loop. But, that does not happen in the non-minified version.

To recreate the problem, create a new Vue project using the Vue CLI. Edit the App.vue file to look like this:

<template>
  <div id="app">
    <Reporting/>
  </div>
</template>

<script>
import Reporting from './components/Reporting'

export default {
  name: 'app',
  components: {
    Reporting
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Edit the main.js file to look like this:

import Vue from 'vue'
import Vue2Filters from 'vue2-filters'

import App from './App.vue'

Vue.config.productionTip = false

Vue.use(Vue2Filters)

new Vue({
  render: h => h(App),
}).$mount('#app')

You'll need to install vue2-filters, so install that using npm install --save vue2-filters.

Add this Reporting.vue component:

<template>
    <div id="Reporting" style="min-height: inherit; display: flex; flex-direction: column;">
        <div class="mainbody">
            <div id="content">
                <ErrorList v-if="error" :error="error"/>
                <table>
                    <thead>
                        <tr>
                            <th scope="col">
                                State
                            </th>
                            <th scope="col">
                                Class
                            </th>
                            <th scope="col">
                                Description
                            </th>
                            <th scope="col">
                                Net Rate
                            </th>
                            <th scope="col">
                                Payroll
                            </th>
                            <th scope="col">
                                Premium Due
                            </th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr v-for="(clazz, index) in classes" :key="index">
                            <td scope="row" data-label="State">
                                {{ clazz.state }}
                            </td>
                            <td data-label="Class">
                                {{ clazz.classCode }}
                            </td>
                            <td data-label="Description">
                                {{ clazz.description }}
                            </td>
                            <td data-label="Net Rate" class="alignright">
                                {{ clazz.netRate | currency }}
                            </td>
                            <td data-label="Payroll">
                                <input type="text" v-model="clazz.payroll"/>
                            </td>
                            <td data-label="Premium Due" class="alignright">
                                {{ premiumDue(clazz) | currency }}
                            </td>
                        </tr>
                    </tbody>
                    <tfoot>
                        <tr class="subtotal">
                            <td colspan="3" aria-hidden="true"></td>
                            <td colspan="2" class="alignright lighter">Premium Total:</td>
                            <td class="alignright">{{ premiumTotal() | currency }}</td>
                        </tr>
                        <tr class="subtotal last">
                            <td colspan="3" aria-hidden="true"></td>
                            <td colspan="2" class="alignright lighter">Tax Total:</td>
                            <td class="alignright">{{ taxTotal() | currency }}</td>
                        </tr>
                        <tr class="grandtotal">
                            <td colspan="3" aria-hidden="true"></td>
                            <td colspan="2" class="alignright lighter">Grand Total:</td>
                            <td class="alignright">{{ (taxTotal() + premiumTotal()) | currency }}</td>
                        </tr>
                        <tr class="formbuttons">
                            <td colspan="4" aria-hidden="true"></td>
                            <td><button class="button-sm purple" @click="onClear">Clear</button></td>
                            <td><button class="button-sm purple" @click="onSubmit">Submit</button></td>
                        </tr>
                    </tfoot>
                </table>
            </div>
        </div>
    </div>
</template>

<script>
/* eslint-disable vue/no-unused-components */

import ErrorList from './shared/ErrorList'

export default {
    name: 'Reporting',
    components: { ErrorList },
    data() {
        return {
            error: null,
            classes: []
        }
    },
    methods: {
        onClear() {
            this.classes.forEach(clazz => {
                clazz.payroll = ''
            })
        },
        validate(lines) {
            for (let line of lines) {
                if (!(/^\d*(\.\d{1,2})?$/.test(line.quantity))) {
                    this.error = { message: 'Payroll must be in number format with no more than 2 places after decimal.' }
                    return false
                }
            }
            this.error = null
            return true
        },
        onSubmit(e) {
            let lines = []
            this.classes.forEach(clazz => {
                lines.push({
                    classCode: clazz.id,
                    quantity: clazz.payroll,
                    rate: clazz.netRate,
                    taxRate: clazz.taxRate
                })
            })
            this.validate(lines)
        },
        premiumDue(clazz){
            if (!clazz.payroll) {
                this.error = null
                return 0
            } else if (/^\d*(\.\d{1,2})?$/.test(clazz.payroll)) {
                this.error = null
                return (clazz.payroll / 100) * clazz.netRate
            } else {
                this.error = { message: 'Payroll must be in number format with no more than 2 places after decimal.' }
                return 0
            }
        },
        premiumTotal() {
            return this.classes.reduce((accumulator, clazz) => {
                return (clazz.payroll) ? accumulator + this.premiumDue(clazz) : accumulator + 0
            }, 0)
        },
        taxDue(clazz){
            return this.premiumDue(clazz) * clazz.taxRate
        },
        taxTotal() {
            return this.classes.reduce((accumulator, clazz) => {
                return (clazz.payroll) ? accumulator + this.taxDue(clazz) : accumulator + 0
            }, 0)
        },
        initialize() {
            this.classes.push({
                classCode: "5540",
                description: "Roofing",
                name: "CC-00002",
                netRate: 12.34,
                state: "CA",
                taxRate: 0.035
            })
            this.classes.push({
                classCode: "8810",
                description: "Clerical",
                name: "CC-00001",
                netRate: 0.68,
                state: "CA",
                taxRate: 0.035
            })
        }
    },
    beforeRouteUpdate(to) {
        this.onClear()
        this.initialize()
    },
    created() {
        this.initialize()
    }
}
</script>

<style scoped>
</style>

Add this ErrorList.vue file (make sure to put this in a subfolder called shared under the components folder):

<template>
    <section>
        <div v-if="error.message">{{ error.message }}</div>
        <div v-if="error.errors && error.errors.length > 0">
            <ul>
                <li v-for="(err, index) in error.errors" :key="index"><h1>{{ err.message }}</h1></li>
            </ul>
        </div>
    </section>
</template>

<script>
  export default {
    name: 'ErrorList',
    props: ['error']
  }
</script>

<style scoped>

</style>

Now run the command npm run build. Then run the command serve -s dist to run the minified code. In the app, enter a non-numeric character into the input and it will cause the browser to crash.

Is there any reason why a minified version of this code would cause an infinite loop?


Solution

  • The problem starts occurring when you have a reference to error somewhere in your template. The Vue dev server will start warning you about 'You may have an infinite update loop in a component render function.'. This is probably what is crashing your built version.

    What does 'You may have an infinite update loop in a component render function.' mean?

    Vue re-renders a template with data when the data in it changes. Nothing odd going on there. If you reference a variable numberOfUnicorns and you add 1, because you spotted one`, you want this reflected on the screen.

    An infinite update loop means that somehow during rendering a variable that is used during rendering, is changed. This is usually caused by functions that are not 'pure' (Wikipedia).

    Why is it happening for you?

    Your method premiumDue sets this.error. As I mentioned earlier, the problem starts occurring when error is used in the template. this.error is passed to ErrorList in your case, and then premiumDue is called, which sets this.error and marks the rendered view dirty. The view is then rerendered. Over. And over. And over.

    The dev server seems to be a bit more forgiving for this kind of error and apparently stops the rerender cycle. The built version is optimised and trusts that you don't make it end up in an infinite cycle... which apparently turns into a crash when it does not turn out to be the case.

    How do you solve it?

    This is the harder part. First you need to rewrite premiumDue so it is pure.

    premiumDue(clazz) {
      if (!clazz.payroll) {
        return 0;
      } else if (/^\d*(\.\d{1,2})?$/.test(clazz.payroll)) {
        return (clazz.payroll / 100) * clazz.netRate;
      } else {
        return 0;
      }
    }
    

    Now your validation no longer works, so lets do something about that. Your validate function checks if all fields are filled in, which is a bit strict for what we wanted to do. Instead we probably want to define some forgiving validate function validatePartial.

    validatePartial() {
      for (let line of this.classes) {
        if (line.payroll && !/^\d*(\.\d{1,2})?$/.test(line.payroll)) {
          this.error = {
            message:
              "Payroll must be in number format with no more than 2 places after decimal."
          };
          return false;
        }
      }
      this.error = null;
      return true;
    }
    

    It is basically the same as validate, but instead of looping over an argument, we use this.classes instead. The error message is only triggered if line.payroll actually has something in it.

    We still need to trigger it though, and I see two options for that. Before, you had it trigger on every keystroke, because every keystroke changed this.classes, which caused a re-render. We can emulate that by creating a watcher on this.classes that triggers the validate function.

    watch: {
      classes: {
        deep: true,
        handler() {
          this.validatePartial();
        }
      }
    }
    

    A slightly less aggressive way of validating would be to use the blur event on your inputs to trigger the validation instead. You would not use the watcher. This way the error only pops up when the user is done typing.

    <input type="text" v-model="clazz.payroll" @blur="validatePartial" />
    

    Edit Vue Template