monorepo setup with parcel 2 (1/n)

TLDR - migrating from Create React App feat Tailwinds, Jest on SWC

This was pretty easy!  Parcel doesn't support a test runner yet, so you're stuck using @swc/Jest to transpile your tests.  Given that Parcel uses swc under the covers, this seems like a reasonable compromise.

Happy path steps:

  1. install parcel as dev dependency
  2. add source for parcel in package.json
  3. tweak your index.html to remove some Create React App flavoring
  4. add script tag to index.html
  5. move static files to source directory
  6. update scripts with preferred dev server port in package.json
  7. update CICD to pick up ./dist instead of ./build
  8. install and configure jest and @swc/jest
  9. add sane tests

why

I am dissatisfied with my javascript build system.  It is an accursed tower of crumbling sand. I hate it.

I have a Node.js server with Nest.js on Fastify running in AWS Lambda.  Testing is Jest with swc (tsc with Jest is unusably slow), but build is actually tsc.  I have a React frontend with craco and tailwindcss.  I'm building using yarn classic.  Hidden behind all of this is webpack 4 and babel.

I wanted to share some code between the two, so I started trying to convert to monorepo using shiny new yarn 3 workspaces.  This started out ok, but then I started to realize how far out of date some of my dependencies were... and then I realized they were stuck like that because of create-react-app and craco.  I started looking into upgrading to the newest version of create-react-app, which claims not to need craco to do tailwindcss... and just got fed up and decided things needed to be more granular.

I started doing a manual webpack and babel config, and then looked around to see if any of the Rust / SWC based tools seemed mature.

So, Parcel

Enter parcel (https://parceljs.org/).  Parcel claims to be a zero config build and package system.  It's built on swc, and it includes a hot load development server.  The new version looks like it has tailwinds support!  Ok.  Cool.  Let's see if we can get this to work.

The Client

First, let's get the client to build and deploy. This should be easier, because I'm a terrible developer and I haven't gotten around to building any front end tests.
https://parceljs.org/getting-started/webapp/

cd packages/client
yarn add --dev parcel

I'm tempted to just rip out a bunch of crap I won't need any more, but lets see if we can get a working commit first.

Anyway, now the directions say to create a index.html, and run yarn parcel src/index.html .  I can tell this isn't quite going to work, because I've got typescript, and my index.html is in public.  Looking through yarn parcel --help it seems like I should build first?

yarn parcel build srcyarn run v1.22.17$ /home/pg/repos/ohhell/packages/client/node_modules/.bin/parcel build src🚨 Build failed.
unknown: Could not find entry: /home/pg/repos/ohhell/packages/client/src

uhhhh.  ok, let's read through the docs... https://parceljs.org/features/cli/#entries

It appears, I have to specify some entries.  Neat.  yarn parcel build . should work, because I've got a package.json... but no go. unknown: Could not find entry: /home/pg/repos/ohhell/packages/client/ .  Looking further at the docs (https://parceljs.org/features/targets/#entries) it seems like I need to add a source entry to my package.json.  Target is supposed to default to dist, so let's leave that for a minute...

  "source": [
    "src/index.tsx",
    "public/index.html"
  ],
yarn parcel build .

yarn run v1.22.17
$ /home/pg/repos/ohhell/packages/client/node_modules/.bin/parcel build .
🚨 Build failed.

@parcel/core: Failed to resolve '%PUBLIC_URL%/favicon.ico' from './public/index.html'

@parcel/resolver-default: URI malformed

.... ok, fix favicon path...? .... and my manifest path? .... ok, it's building...

dist/src/index.js                       248.17 KB     8.43s
dist/src/index.css                      306 B     3.62s
dist/public/index.html                  512 B     8.89sdist/favicon.67839d1f.ico             1.37 KB     161ms
dist/public/manifest.webmanifest        248 B    19.68sDone in 36.10s.

Ok, it built!

yarn parcel serve
yarn run v1.22.17
$ /home/pg/repos/ohhell/packages/client/node_modules/.bin/parcel serve
Server running at http://localhost:1234
✨ Built in 853ms

uhhhh not so good.  Browsing to http://localhost:1234 I do get the right favicon, but I get a blank screen and a console note that says [parcel] 🚨 Connection to the HMR server was lost . FORGET YOU, MAN

I have no error in the bash console.  Hmmm.  Looking at index.js, it does looks like my code is transpiled and minified in there.  I see my domain class names doing stuff. parcel serve src/index.tsx is no better. Worse, actually, I have to navigate to /public/index.hmtl.

.... on closer inspection, my index.html is missing a script tag.  It looks like the React build process was auto injecting stuff in there.  Current index.html:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="Oh Hell - A fun, witchy card game and bar room" />
  <link rel="manifest" href="manifest.json" />
  <title>oh hell</title>
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
</body>

</html>

Let's check stack overflow.... sure enough https://stackoverflow.com/a/49605484/7032909

  • move all public files to src
  • get rid of %PUBLIC% variables... done that already
  • add <script type="module" src="./index.tsx" /> to the end of the body tag
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="MYOB" />
  <link rel="manifest" href="manifest.json" />
  <title>oh hell</title>
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">
    <script></script>
  </div>
  <script type="module" src="./index.tsx" />
</body>

</html>

Ok!  The site builds and is accessible.  CORS is complaining, probably because the port is different, and my stylesheets are.... missing.  Update package.json scripts to set the port:

  "scripts": {
    "start": "parcel serve -p 3000",
    "build": "parcel build",
    "test": "test"
  },

That fixes CORS, but style is still absent.  Here's my tailwind.config.js:

module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: { 
  },
  variants: {
    extend: {
      overflow: ['hover'],
      padding: ['hover']
    },
  },
  plugins: [],
}

My tailwinds dependencies are all sort of out of date, so let's ... make a wip commit and then upgrade those.

  "devDependencies": {
    "@parcel/packager-raw-url": "^2.2.1",
    "@parcel/transformer-webmanifest": "^2.2.1",
    "autoprefixer": "^10",
    "parcel": "^2.2.1",
    "postcss": "^8",
    "tailwindcss": "^3.0.18"
  }
 "devDependencies": {
    "@parcel/packager-raw-url": "^2.2.1",
    "@parcel/transformer-webmanifest": "^2.2.1",
    "autoprefixer": "^10",
    "parcel": "^2.2.1",
    "postcss": "^8",
    "tailwindcss": "^3.0.18"
  }
package.json

I ended up tweaking a couple random things here...

module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    container: {
      center: true,
    },
  },
  variants: {
    extend: {
      overflow: ['hover'],
      padding: ['hover']
    },
  },
  plugins: [],
}
tailwind.config.js
{
  "plugins": {
    "tailwindcss": {},
    "autoprefixer": {}
  }
}
.postcssrc.json

I had a bunch of very helpful warnings to help me figure that all out, THANK YOU PARCEL-IANS.

I adjust my CICD scripts to point to dist instead of build... and build fails because it can't find favicon again.  I try adjusting the index.html to explicitly call out the favicon, and the build still fails in the pipe and works locally.

Sonofabitch I never commited the new location of favicon.  Commit the file, push the commit, rerun CIDC... and we're good to go.

I want to remove some of the rickety dependencies that caused this whole issue in the first place.  There's some that are easy to pull out: react-bootstrap, node-sass, react-scripts.  The next batch are a bunch of Jest depenendencies I haven't used yet, so let's set up a basic unit test and add it to CICD.

import { MessageBus } from './MessageBus'

describe('Messagebus', () => {
  it('should construct', () => {
    const messageBus = new MessageBus()
    expect(messageBus.constructor.name).toEqual('MessageBus')
  })
})

This is kind of an experiment. I have a a few tests like this on the server-side.  They started to break when I updated SWC, and I'm curious if the version parcel uses has this behavior.

Should I expect a specific string to equal the internal name of a class in javascript? That's another question, but that's how I wish things were.

After doing some research... I find out that test runners aren't currently supported within parcel, and you need to run babel and SWC along side it.  FROGS.

Luckily, I already have something similar working in my server config.  While I'm currently building and deploying using tsc, tests are run using @swc/jest.  This includes transpilation to javascript.  That server setup works.... ok.  It builds.  Tests run very fast.  The stack traces when things fail are not terribly helpful, although this is sort of a broader Jest / Node issue.

After a couple fits and starts:

  • needed to use a mock localStorage
  • needed to set "testEnvironment": "jsdom" in jest config
  • within a test, I needed to wrap App in a BrowserRouter so it can useLocation

.... I end up with these config files.

{
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "tsx": true,
      "decorators": true,
      "dynamicImport": false
    },
    "transform": {
      "legacyDecorator": true,
      "decoratorMetadata": true
    },
    "target": "es5"
  },
  "module": {
    "type": "commonjs",
    "noInterop": true
  }
}
.swcrc
module.exports = {
  "roots": [
    "./", "./src", "./test"
  ],
  "moduleDirectories": ["node_modules", "src", "test"],
  "modulePathIgnorePatterns": ["built"],
  "testMatch": [
    "**/__tests__/**/*.+(ts|tsx|js)",
    "**/?(*.)+(spec|test).+(ts|tsx|js)"
  ],
  "testEnvironment": "jsdom",
  "transform": {
    "^.+\\.(ts|tsx)$": '@swc/jest'
  },
  "setupFiles": ["./test/scripts/setUnitEnv.js"]
}
jest.config.fast.js

Now, when I run tests, Jest complains that I'm leaving hooks open.  That's a problem for a different day.