Page 4
How I used Parcel to "manually" bundle CSS files in a Remix app
May 31, 2023
0 comments JavaScript
I recently switch from Nextjs to Remix for my personal website. One thing I struggled with was to have it merge individual .css
files into one. So I solved it with the Parcel CLI. This blog post demonstrates how.
The problem
Note, first of all, this talks about the global CSS. You can and should still employ CSS Modules or something equivalent for CSS that is tied directly to a React component.
But global CSS has its place and purpose. The problem is that there's no convenient way to bundle multiple little .css
files into one which you can then nest into routes in Remix.
The way you inject CSS into a Remix page is like this:
import highlight from "~/styles/highlight.css";
import blogpost from "~/styles/blogpost.css";
...
export function links() {
return [
{ rel: "stylesheet", href: highlight },
{ rel: "stylesheet", href: blogpost },
}
And for the record, suppose you have a nested route that needs those, and another one you do:
import banner from "~/styles/banner.css";
import { links as rootLinks } from "./_index";
...
export function links() {
return [
...rootLinks().filter((x) => !x.extra),
{ rel: "stylesheet", href: banner },
];
}
This will nicely pick up those source .css
files, minify them and produce in the final HTML SSR output:
<link rel="stylesheet" href="/build/_assets/highlight-KI4AX52K.css"/>
<link rel="stylesheet" href="/build/_assets/blogpost-75V4EYTP.css"/>
Nice. Http2 is famously good at parallel downloads. But even that has its physical limits. Especially if you have many little .css
files that make up all the CSS you need. Now you have multiple files that can get stuck on the network. Yes, you might be able to update 1 and keep caching the others if their fingerprint don't change, but this is likely to be rare.
Parcel to the rescue
I solved it by using the Parcel CLI. In package.json
I have:
"parcel:build": "parcel build --dist-dir app/styles/build app/*.css",
And in app/global.css
I have this:
/* This is app/global.css */
@import "../node_modules/@picocss/pico/css/pico.css";
@import "./styles/globals.css";
@import "./styles/message.css";
@import "./styles/nav.css";
@import "./styles/comments.css";
@import "./styles/carbonads.css";
@import "./styles/carbonads-outer.css";
@import "./styles/modal-search.css";
That means, that Parcel will bundle all of these app/*.css
files into 1 app/styles/build/global.css
Now, I can refer to that built on in the Remix app:
import global from "~/styles/build/global.css";
...
export function links() {
return [
{ rel: "stylesheet", href: global },
]
}
Build vs. dev
Ok, so that explains how to bundle individual CSS files before you actually use the bundled CSS files. Remix doesn't care (a good thing).
At this point, we've modularized the problem. Now Parcel can do what it does best (CSS bundling (among other things it can do)) and Remix can do what it does (serving the .css
files into the HTML).
But just like it's ergonomically pleasant to bundle CSS files like this, we still want it so that you don't have to manually run a separate step to build the bundle every time you edit an individual source .css
file (e.g. app/styles/nav.css
)
Here's how I solved that split up by Dev and Build
Build
"scripts": {
- "build": "remix build",
+ "build": "npm run parcel:build && remix build",
+ "parcel:build": "parcel build --dist-dir app/styles/build app/*.css",
Now, npm run build
will do both things.
Dev
"scripts": {
"dev": "npm-run-all build --parallel \"dev:*\"",
"dev:node": "cross-env NODE_ENV=development esrun --watch ./server.ts ",
"dev:remix": "remix watch",
+ "dev:parcel": "parcel watch --dist-dir app/styles/build app/global.css",
In conclusion
I admit, I'm a CSS Modules fan-boy and it saddens me how much global CSS I have. One thing at a time, I guess. They both have their powers; global and modular CSS, but I'll admit that my own personal site still relies a bit too much on global CSS. At least, little goes to waste because Remix makes it relatively easy to pick exactly which files you need for individual routes.
Be careful with Date.toLocaleDateString() in JavaScript
May 8, 2023
4 comments Node, MacOSX, JavaScript
tl;dr; Always pass timeZone:"UTC"
when calling Date.toLocaleDateString
The surprise
In my browser's web console:
>>> new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric"}) "26"
On my server located in the same time zone:
Welcome to Node.js v16.13.0. Type ".help" for more information. > process.env.TZ undefined > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric"}) '26'
Here on my laptop:
Welcome to Node.js v16.20.0. Type ".help" for more information. > process.env.TZ undefined > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric"}) '27'
What! Despite $TZ
not being set, it formats according to something else.
02:50 Zulu means, to me, in the US Eastern time zone, the day before.
Why this matters
I kept getting this production error from React that the SSR-rendered HTML differed from the client-side rendered HTML. Strangely, I could never reproduce this locally and the error doesn't say what's different. All the Stack Overflow suggestions and Google results speak of the most basic easy things to check. It's not unusual that this happens when dealing with dates because even though the database (PostgreSQL) stores the dates in full UTC, sometimes when data travels via app servers through JSON pipelines, date formatting can drop important bits.
But here, '2014-11-27T02:50:49Z'
is specific.
What made this so incredibly hard to debug was that it worked on one page but not on the other even though the two had the same exact component code. I broke it apart thinking there was something nasty in the content of the Markdown-rendered HTML. No. The reason it only happened on some pages was that I had a function that looked like this:
export function formatDateBasic(date: string) {
return new Date(date).toLocaleDateString("en-us", {
year: "numeric",
month: "long",
day: "numeric",
});
}
And, different pages listed, almost non-deterministic, with different dates for related content which was referred to along with their dates. So on one page, there might be a single date that formats differently in EDT (Eastern daylight-saving time) compared to UTC. For example, Apr 1 at 18:00 Zulu, is still Apr 1 in EDT.
The explanation
I'm sorry that I don't understand this better, but Node's implementation of Date.toLocaleDateString
does more than depend on process.env.TZ
. I think $TZ
is just a way to gain control.
For example, start the node
REPL like this:
On my Ubuntu 20.04 server:
$ TZ=utc node Welcome to Node.js v16.20.0. Type ".help" for more information. > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric"}) '27'
On my MacBook:
❯ TZ=utc node Welcome to Node.js v16.13.0. Type ".help" for more information. > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric"}) '27'
To find out what timezone your computer has:
On Ubuntu:
$ timedatectl Local time: Mon 2023-05-08 12:42:03 UTC Universal time: Mon 2023-05-08 12:42:03 UTC RTC time: Mon 2023-05-08 12:42:04 Time zone: Etc/UTC (UTC, +0000) System clock synchronized: yes NTP service: active RTC in local TZ: no
On macOS:
❯ sudo systemsetup -gettimezone Password: Time Zone: America/New_York
The solution
Setting TZ
is probably a good thing. That can get a bit tricky though. Your code needs to run consistently on your laptop, in GitHub Actions, on a VPS server, in an Edge cloud function, etc.
A better way is to force Date.toLocaleString
to be fed a timezone. Now it's controlled at the highest level:
export function formatDateBasic(date: string) {
return new Date(date).toLocaleDateString("en-us", {
year: "numeric",
month: "long",
day: "numeric",
+ timeZone: "UTC"
});
}
Now, it no longer depends on the OS it runs on.
On my Ubuntu server:
Welcome to Node.js v16.20.0. Type ".help" for more information. > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric", timeZone: "UTC"}) '27'
On my macOS:
Welcome to Node.js v16.13.0. Type ".help" for more information. > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric", timeZone: "UTC"}) '27'
Fun fact
I once made it unnecessarily weird for me in the debugging session, when I figured out about the timeZone
option. What I ran was this:
Welcome to Node.js v16.13.0. Type ".help" for more information. > new Date('2014-11-27T02:50:49Z').toLocaleDateString("en-us", {day: "numeric", zimeZone: "UTC"}) '26'
I expected it to be '27'
now but why did it revert?? Notice the typo? And Date.toLocaleDateString
won't throw an error for passing in options it doesn't expect.
How to run a GitHub Action workflow step if a file exists
April 24, 2023
2 comments GitHub
Suppose you have a GitHub Action workflow that does some computation and a possible outcome is that file comes into existence. How do you run a follow-up step based on whether a file was created?
tl;dr
- name: Is file created?
if: ${{ hashFiles('test.txt') != '' }}
run: echo "File exists"
The "wrong" way
Technically, there's no wrong way, but an alternative might be to rely on exit codes. This would work.
- name: Check if file was created
run: |
if [ -f test.txt ]; then
echo "File exists"
exit 1
else
echo "File does not exist"
fi
- name: Did the last step fail?
if: ${{ failure() }}
run: echo "Last step failed, so file must have maybe been created"
The problem with this is that not only leaves a red ❌ in the workflow logs, but it could also lead to false positives. For example, if the step that might create a file is non-trivial, you don't want to lump the creation of the file with a possible bug in your code.
A use case
What I needed this for was a complex script that was executed to find broken links in a web app. If there were broken links, only then do I want to file a new issue about that. If the script failed for some reason, you want to know that and work on fixing whatever its bug might be. It looked like this:
- name: Run broken link check
run: |
script/check-links.js broken_links.md
- name: Create issue from file
if: ${{ hashFiles('broken_links.md') != '' }}
uses: peter-evans/create-issue-from-file@433e51abf769039ee20ba1293a088ca19d573b7f
with:
token: ${{ env.GITHUB_TOKEN }}
title: More than one zero broken links found
content-filepath: ./broken_links.md
repository: ${{ env.REPORT_REPOSITORY }}
labels: ${{ env.REPORT_LABEL }}
That script/check-links.js
script is given an argument which is the name of the file to write to if it did indeed find any broken links. If there were any, it generates a snippet of Markdown about them which is the body of filed new issue.
Demo
To be confident this works, I created a dummy workflow in a test repo to test. It looks like this: .github/workflows/maybe-fail.yml
Automatically 'npm install'
April 6, 2023
0 comments Node, JavaScript
I implemented this at work recently and although it felt like a hack, I've come to like it and it's been very helpful to our many contributors.
As (Node) engineers, we know that you should keep your node_modules
up-to-date by running npm install
periodically or every time you git pull
from the upstream. It could be that some package got upgraded last night since you git pulled last time.
But not everyone remembers to run npm install
often enough. They might do git pull origin main && npm start
and now the code that starts up depends on some latest version that was upgraded in package.json
and package-lock.json
.
How we solved it was that we added this script:
node script/cmp-files.js package-lock.json .installed.package-lock.json || npm install && cp package-lock.json .installed.package-lock.json
And it's hooked up as a script in package.json
called prestart
:
"scripts": { ... "prestart": "node script/cmp-files.js ...", ... }
Now, every time you run npm start
to start up the local development server, it will run that piece of bash. No more having to remember to run npm install
after every git pull
.
A note on performance
The npm install
command is fast when all packages are already updated. You can see it with:
# First time
$ npm install
# Second time when nothing should happen
$ time npm install
...
2.53s user 0.37s system 134% cpu 2.166 total
So it only takes 2 seconds. Not bad.
$ time node script/cmp-files.js package-lock.json .installed.package-lock.json
...
0.08s user 0.03s system 100% cpu 0.110 total
But 0.08 seconds is better :)
The comparison script
The cmp-files.js
script looks like this:
#!/usr/bin/env node
// Given N files. Exit 0 if they all exist and are identical in content.
import fs from 'fs'
import { program } from 'commander'
program.description('Compare N files').arguments('[files...]', '').parse(process.argv)
main(program.args)
function main(files) {
if (files.length < 2) throw new Error('Must be at least 2 files')
try {
const contents = files.map((file) => fs.readFileSync(file, 'utf-8'))
if (new Set(contents).size > 1) {
process.exit(1)
}
} catch (error) {
if (error.code === 'ENOENT') {
process.exit(1)
} else {
throw error
}
}
}
The file .installed.package-lock.json
file is added to the repo's .gitignore
Note; given how well this works for running before npm start
we can probably add this to a post-checkout git
hook too.
Benchmarking npm install with or without audit
February 23, 2023
1 comment Node, JavaScript
By default, running npm install
will do a security audit of your installed packages. That audit is fast but it still takes a bit of time. To disable it you can either add --no-audit
or you can...:
❯ cat .npmrc
audit=false
But how much does the audit take when running npm install
? To find out, I wrote this:
import random
import statistics
import subprocess
import time
from collections import defaultdict
def f1():
subprocess.check_output("npm install".split())
def f2():
subprocess.check_output("npm install --no-audit".split())
functions = f1, f2
times = defaultdict(list)
for i in range(25):
f = random.choice(functions)
t0 = time.time()
f()
t1 = time.time()
times[f.__name__].append(t1 - t0)
time.sleep(5)
for f_name in sorted(times.keys()):
print(
f_name,
f"mean: {statistics.mean(times[f_name]):.1f}s".ljust(10),
f"median: {statistics.median(times[f_name]):.1f}s",
)
Note how it runs a lot of times in case there are network hiccups and it sleeps between each run just to spread out the experiment over a longer period of time. And the results are:
f1 mean: 2.81s median: 2.57s f2 mean: 2.25s median: 2.21s
Going by the median time, the --no-audit
makes the npm install
16% faster. If you look at the mean time dropping the --no-audit
can make it 25% faster.
How to intercept and react to non-zero exits in bash
February 23, 2023
2 comments Bash, GitHub
Inside a step in a GitHub Action, I want to run a script, and depending on the outcome of that, maybe do some more things. Essentially, if the script fails, I want to print some extra user-friendly messages, but the whole Action should still fail with the same exit code.
In pseudo-code, this is what I want to achieve:
exit_code = that_other_script() if exit_code > 0: print("Extra message if it failed") exit(exit_code)
So here's how to do that with bash
:
# If it's not the default, make it so that it proceeds even if
# any one line exits non-zero
set +e
./script/update-internal-links.js --check
exit_code=$?
if [ $exit_code != 0 ]; then
echo "Extra message here informing that the script failed"
exit $exit_code
fi
The origin, for me, at the moment, was that I had a GitHub Action where it calls another script that might fail. If it fails, I wanted to print out a verbose extra hint to whoever looks at the output. Steps in GitHub Action runs with set -e
by default I think, meaning that if anything goes wrong in the step it leaves the step and runs those steps with if: ${{ failure() }}
next.