Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Notes from migrating React app from CRA to Vite

INTRO

I've recently made the decision to migrate my vim.morzel.net pet-project from Create React App to Vite. To be honest, I was quite content with how CRA (with a bit of React App Rewired) functioned, and updating might not have been necessary. However, I wanted to use this migration as practice before possibly employing Vite on something more serious (where the speed and active development of Vite might prove a blessing).

Here's a summary of the front-end part of vim.morzel.net as reported by cloc on src directory:

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
TypeScript                      94            535             88           3382
CSS                             32            268             10           1613
JavaScript                       3             71             26            364
Markdown                         1             28              0             58
JSON                             1              0              0             15
SVG                              1              0              0              1
-------------------------------------------------------------------------------
SUM:                           133            902            124           5433
-------------------------------------------------------------------------------

The project is done mostly with TypeScript and CSS (modules). JS lines come primarily from glue code for WebAssembly module for drills (which is done in Rust in separate repository).

Most notable dependencies are Redux (with Toolkit), React Syntax Highlighter, Fontsource and React Testing Library. Full list with links is at the bottom of this page.

As you can see, the project is small, but nonetheless useful as a testing ground for migration to Vite.

Application is deployed on DigitalOcean droplet with Ubuntu 20.04, NGINX 1.18.0, Node.js 17.3.0 and PM2 5.10.

In this post I'll note my approach for the migration (which worked quite well) and the issues I ran into. Hopefully you will find some useful information here in case you plan to switch to Vite too.

 

PREPARING FOR MIGRATION

Switching dev/build tooling sounded like a good occasion to upgrade the React version too (from 16.9.0 to 18.2.0). This went surprisingly smoothly, I just needed to update to new application root (details here).
Once on latest React, I went for newest Jest and React Testing Library without any blockers (wasn't sure yet about trying Vitest).

 

A CLEAN SLATE

Initially the plan was to replace CRA features with Vite as described in this article or this one, but then I thought that I'm too much of a noob in Vite and I would rather start with a nice and clean Vite project and move the screens to that fresh setup. So I've created a branch for the Vite version and put there only the results of running Vite scaffolding (v4.3.9) for React with TypeScript and SWC. Then I checked out the CRA version (master branch) to another folder so I could easily move files around and have the two applications running side by side for comparison. Putting content from old version to the new was quite easy: basically copy & paste of folders with features, utils and overall app setup (like Redux store). Then copy some style and config files, favicon etc. A bit more attention was needed for putting chunks of code in index.html, src/main.tsx and src/App.tsx. I of course had to install a couple of dependencies that the app needed but were not part of Vite scaffolding...

 

SOME HURDLES

There were a couple of things that required adjustments, here they are in random order - I might have forgot some, sorry :)

Port configuration

By default Vite runs dev server on port 5173, CRA does it on 3000. Since I had a couple of bookmarks with :3000 I wanted to keep it. It's easy to change: just add this to your vite.config.ts:

server: {
  port: 3000,
  open: true
}

The open flag will automatically launch browser when server starts (just like CRA does by default).

You can also add a config for running production build locally with npm run preview (no need for installing serve like in CRA):

preview: {
  port: 3000,
  open: true
}

Accessing config from .env files

The CRA version used .env files (like .env.development or .env.production) with the help of env-cmd package. Chosen values (prefixed with REACT_APP_) were automatically included in UI and accessed like this: process.env.REACT_APP_SOMETHING in code. Fortunately Vite has built-in support for .env files (thanks to dotenv), but exposed values should be prefixed with VITE_. Access to values is slightly different, there is no process.env but you should use import.meta.env (for example: import.meta.env.VITE_SOMETHING)

DEV or PROD?

In CRA one could check process.env.NODE_ENV value to see if application was build in development or production mode. In Vite this should be changed to check of import.meta.env.DEV or import.meta.env.PROD boolean properties.

No need for %PUBLIC_URL%

While copying some things into new index.html file form CRA version I forgot to remove %PUBLIC_URL% placeholders, these should not be present in Vite version.

Injecting values into views

In CRA I've used preval.macro to inject build timestamp into a diagnostic feature. Maybe it was possible to make it work in Vite too but I went with source transform in vite.config.ts instead:

plugins: [
  react(),
  {
    name: 'build-timestamp-placeholder',
    transform(src, id) {
      if (id.endsWith('Footer.tsx') && src.includes('BUILD-TIMESTAMP-PLACEHOLDER')) {
        const date = new Date();
        return src.replace('BUILD-TIMESTAMP-PLACEHOLDER', date.toISOString() + date.getTimezoneOffset());
      }
    }
  }
]

Update (2024-01-28): You can also define global constant replacement to have a built timestamp. This Stack Overflow answer describes it nicely, it looks like a better solution. I'm leaving the part above because it won't hurt to know how to write a source transform :)

Update (2024-05-02): There's one more thing you might want to tweak in the config: generated CSS module class names.

Missing robots

When I run Lighthouse on Vite version, it told me that the app was missing robots.txt file (bad from search engines perspective). Indeed, the file was missing, so I copied it to the public folder from CRA version and problem went away.

Missing caching on static files

In CRA version, the static files of built application (*.js, *.css, *.wasm, *.woff2...) were kept in folder named static. In Vite this went to a folder named assets and made Lighthouse rightfully angry about missed opportunity for caching static resources. Since I run my app on NGINX, I had to update a rule that sets caching headers on files from folder named static to the assets folder.

This is how traffic looked like before enabling caching on static files:

Trafic without caching... Click to enlarge...

and this is with caching:

Trafic with caching... Click to enlarge...

Build output directory

When you run a build on CRA the results go into a directory named build, in Vite it goes to dist. I've had a few scripts that assumed build so these had to switch to dist.

Precise file extensions

Vite doesn't like it when a file has *.js or *.ts extension but contains JSX. It turned out that I had one rogue file like it, renamed, fixed.

Tweaking lint rules

The default ESLint rules in Vite were a bit too harsh for my toy project, so I got to add a few exceptions into .eslintrc.cjs config (like '@typescript-eslint/no-non-null-assertion': 'off')

Switching to Vitest

I've heard some good things about Vitest and since full integration of Jest into Vite is currently a bit complicated I decided to give Vitest a try.

This is the test config I have in vite.config.ts:

test: {
  globals: true,
  environment: 'jsdom',
  setupFiles: './src/test/setup.ts',
  // you might want to disable it, if you don't have tests that rely on CSS
  // since parsing CSS is slow
  css: true
},

and this is the entire content of import src/test/setup.ts;

import '@testing-library/jest-dom';

Aside of config, I had to change usage of jest.fn() to vi.fn() to make the test that use function mocks work.

Oh, one more thing: in CRA/Jest test run complained about one of the files generated for WebAssembly module (maybe it could've been fixed with transformIgnorePatterns in Jest config) but in Vite/Vitest I don't see this problem anymore. 

 

VITE ON MASTER

Once I was happy with how the app was working on a branch created for migration it was time to put Vite on the main (master) branch. This was very easy with the use of merge --strategy=ours (details) because I refrained from making any changes on master while working on the migration. After the merge, I have a clean Vite setup while Git history of files that existed before migration is preserved. Nice.


RESULTS

On localhost

Here's some comparison between the CRA and Vite versions while working on localhost (both after migration to React 18.2.0):

  • Running production build (average of 3 runs, duration as reported by real line in Linux time command output): CRA: 9s, Vite: 4.5s.
  • Starting production build locally with serve -s build in CRA and npm run preview in Vite (average of 3 runs, with time counted from running the command to a functional app appearing in new Chrome tab): CRA 3s, Vite: 0.8s
  • Observing change in a screen while running in DEV mode (hot module reload): CRA: 0.5s, Vite: practically instant :)
  • Running tests: well, frankly speaking I have (currently, yeah) very little tests on that hobby project so I can't offer very meaningful numbers, I can only tell that Vite/Vitest looks about 2.5x faster than CRA/Jest.

To sum up: Vite is noticeably faster but that doesn't mean that CRA version is slow (the difference will start to be significant on a larger application).

On server

What about the deployed application? Unfortunately I don't have data from CRA before updating React so here are the Lighthouse (navigation/desktop with forced clear storage) scores from CRA on React 16.9.0 (left) and Vite on 18.0.2 (right):

Lighthouse scores for CRA vs Vite (navigation, desktop, with forced cache clear)... Click to enlarge...


These are the results for Vite on mobile (sorry, no data for CRA version):

Lighthouse scores for Vite (navigation, mobile, with forced cache clear)... Click to enlarge...


This is the result for mobile but without forced clear storage (so this simulates returning visitor and benefits from static assets cache):

Lighthouse scores for Vite (navigation, mobile, without forced cache clear)... Click to enlarge...

 

Safer ag-Grid (React) Column Definitions with TypeScript

TL;DR

In JavaScript, an ag-Grid column is bound to a row property by assigning a string to field config. This is quite fragile: a typo or change in row model will cause and empty column in the grid. Fortunately, a much safer binding is possible thanks to TypeScript's keyof operator. Check grid component example and demo page.

 

SAFER WITH TYPES

I'm continuing my little (lockdown-boredom driven) series on ag-Grid usage with React. Previous posts (hooks, resize, renderers) were done with JavaScript, now it's time to present benefits TypeScript!

ag-Grid docs show an easy way of defining the binding between column and a row object property:

columnDefs: [
    { headerName: 'Athlete', field: 'athlete' } 
]

With the above column definition, the grid will look for athlete property (in objects passed as row data) to populate the Athlete column. Such column biding is very easy to setup but it's also easy to break. If you make a typo or change property name in row data, the grid will render empty cells! You might try to remedy this with constans but why not let the TypeScript do the work for you?

Assuming that your React project has TypeScript enabled (easy-peasy with CRA), making type-safe column bindings requires just two things:

  • declaring a type (or an interface) that models grid rows,
  • using property name retrieved with keyof to set column's field.

 

EXAMPLE (THE STARS)

Let's say we want to show a grid with a list of brightest starts (live version):

Brightest stars grid... Click to enlarge...

The grid has 6 columns, so let's create Star.ts file with a type that models the rows:

type Star = {
    rank: number;
    magnitude: number;
    name: string;
    designation: string;
    distance: number;
    spectral: string;
}

export default Star;

Once we have the type, we can use it to define grid columns. Below is the entire (tsx) code needed to define grid component (in functional flavor):

import React from 'react';
import { ColDef } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';

import Star from './Star';
import brightestStars from './brightestStars'

import 'ag-grid-community/dist/styles/ag-grid.css';
import 'ag-grid-community/dist/styles/ag-theme-balham.css';

const fieldName = (name: keyof Star) => name;

const columnDefs: ColDef[] = [
    {
        headerName: 'Rank',
        field: fieldName('rank'),
        width: 80
    },
    {
        headerName: 'Visual magnitude (mV)',
        field: fieldName('magnitude'),
        width: 170
    },
    {
        headerName: 'Proper name',
        field: fieldName('name'),
        width: 180
    },
    {
        headerName: 'Bayer designation',
        field: fieldName('designation'),
        width: 150
    },
    {
        headerName: 'Distance (ly)',
        field: fieldName('distance'),
        width: 120
    },
    {
        headerName: 'Spectral class',
        field: fieldName('spectral'),
        width: 130
    }
];

const StarsGrid: React.FC = () => {
    return (
        <div className='ag-theme-balham'>
            <AgGridReact
                defaultColDef={{
                    sortable: true
                }}
                columnDefs={columnDefs}
                rowData={brightestStars}
            />
        </div>
    );
}

export default StarsGrid;

Have you noticed that the file has an import of ColDef from ag-grid-community package? This is not required but ag-Grid comes with type definitions and these make development a lot easier. Because columnDefs is declared as an array of ColDef the IDE (I'm using Visual Studio Code) is able to offer suggestions on column properties and will instantly highlight any mistake:

ColDef type sugestions...

Notice that the Star type is imported too. It is needed in the line that provides static names of Star properties:

const fieldName = (name: keyof Star) => name;

This little arrow function is later used while field mapping is defined:

{
    headerName: 'Visual magnitude (mV)',
    field: fieldName('magnitude'),
    width: 170,
},

At first glance it doesn't look very useful... The 'magnitude' is still a string? Let's see what happens if 'dsignation' typo is made and Star model is modified by removing the spectral property:

Mapping errors detected... Click to enlarge...

IDE immediately highlights the errors and TypeScript won't compile. Static typing FTW!

 

DEMO APP 

The app was built in React 16.13.1, ag-Grid Community 23.1.1TypeScript 3.7.0 and tested in Chrome 83, Firefox 76 and Edge 44.

If you want to run the app on your machine: clone the repo, do npm install and npm start (just like with any other project initiated with create-react-app)...

 

HOW DOES IT WORK?

TypeScript 2.1 introduced keyof operator that creates a type that lists property names. In our case keyof Star yields a type that has a union of strings containing 6 property names that exist in Star. In fact you could create such type manually:

type TheNames = "rank" | "magnitude" | "name" | "designation" | "distance" | "spectral";

and it would be the same as a type created with: 

type TheNames = keyof Star;

The keyof option is clearly a better choice because the type will be amended automatically whenever Star type changes its properties.

The fieldName arrow function used in column mappings makes use of keyof to limit the acceptable strings:

const fieldName = (name: keyof Star) => name;

Thanks to keyof, TypeScript will reject any string passed to fieldName function which does not belong to a union of Star property names. Nice, no runtime supersizes!