Two bugs, two green builds: the JS escape that isn't, and the clock that crashes Safari

I was re-reading the ICS output file before merging the stream schedule fix. The RRULE property had a bare semicolon in it. According to RFC 5545, semicolons in that context need escaping. I’d written the escape. The file showed ; anyway.

Back to the source: '\;'. In a JavaScript string literal, \; is not a recognised escape sequence. The backslash is silently discarded. The runtime writes ; to the file, which is the unescaped character, which is also what any compliant calendar parser reads as a property separator. I’d fixed the wrong thing.

I’d written the test alongside the implementation, and it contained the same string. Both the assertion and the expectation were wrong in the same direction. The test passed every time. The build was green.

That bug was caught by re-reading the output file before merge. Not by the test.

The semicolon fix

The correct escape in a JS string literal is '\\;'. One backslash to escape the JS parser, one to produce the literal character \ in the string, which is then what the ICS serialiser emits.

This is a JS-specific trap. Python, Ruby, and most other server-side languages handle '\;' differently. Some refuse it, some treat it as the two characters \ and ; intact. JavaScript silently drops the backslash and moves on. No warning, no type error. The runtime’s string parsing is doing exactly what the spec says, and the spec says unrecognised escape sequences produce the escaped character with the backslash stripped.

The fix was three characters: add a second backslash. Propagate the same fix to the test. Run the suite. Both now pass against the correct string.

What failed here wasn’t the test runner. It was the test author. I wrote a test that checked the wrong output, and the thing it was checking looked right on a quick read because '\;' is plausible as “semicolon with a backslash in front of it.” The resolution is the same one from dealing with launchd TCC: don’t trust the read, check the output. Re-reading the rendered file, not the source, is where the bug was visible.

The WebKit clock crash

Same day, different part of the codebase. The hub clock (the “Sun 25 Oct, 19:24” time display on the homepage) was crashing iOS Safari with “Application error” after #554 merged. Reproduced immediately in Playwright WebKit.

The original clock implementation took a formatted string from Intl.DateTimeFormat.format() and parsed it with a regex. The regex was built against the string shape V8 produces: "Sun 25 Oct, 19:24". Weekday, date, month, comma, then the time.

WebKit’s ICU library renders the same locale and format options differently: "Sun 25 Oct at 19:24". Preposition instead of comma. The regex built for the V8 shape matched nothing in the WebKit output. Downstream components got undefined back. React error #418 fired on hydration mismatch, and Safari rendered a blank error screen.

The build was green. TypeScript passed. CI runs against Node (V8). The Playwright webkit browser had been running the clock tests, but the assertions checked the rendered HTML output. That output reflected whatever default the component fell back to when the regex returned undefined, not the specific parsed parts that had broken.

A passing Playwright test that doesn’t assert the specific value that fails isn’t a passing test for that value. It’s a passing test for everything except that value.

The fix

Intl.DateTimeFormat.formatToParts() returns date components as a structured array rather than a locale-formatted string:

{ type: 'weekday', value: 'Sun' }
{ type: 'day', value: '25' }
{ type: 'month', value: 'Oct' }
{ type: 'hour', value: '19' }
{ type: 'minute', value: '24' }
// …plus literal separators that differ between engines

Building the clock string by indexing into the parts array by type means the literal separators (comma in V8, “at” in WebKit) never appear in the output. Both engines produce the same part labels; only the literals differ, and the assembled string doesn’t use them.

The regex is gone. The locale string is never parsed. Two assertions added to the Playwright suite: one for the V8 clock shape and one for the WebKit clock shape, checking the actual text content of the time element.

The test now covers what was broken.

What both bugs have in common

Neither was a logic error. Both were assumption errors about environment.

The semicolon bug assumed that '\;' in a JS string produces \;. It doesn’t. The assumption isn’t wrong in most languages. It’s wrong in JS, and the test shared the assumption so it couldn’t surface the discrepancy.

The WebKit clock bug assumed Intl.DateTimeFormat output shape was stable across JS engines. It isn’t. The assumption holds for V8 and fails for JavaScriptCore. The test suite ran in V8 and didn’t cover the divergence.

Common failure mode: the test author and the implementation author are the same person, working from the same model of the environment. Where that model is wrong, both the implementation and the test are wrong in the same direction. Running the suite more times doesn’t help.

The semicolon bug was caught by a human re-reading the output file. Brittle safety net. “Re-read the rendered output before merge” is a habit, not a gate.

The WebKit clock bug was caught by a crash on iOS. That’s not a safety net at all.

What I’d do differently

Validate at the format boundary. The semicolon bug was in the rendered ICS file, not the source string. The test asserted on source-level expectations. A test that writes the ICS and reads it back, treating the file as the system under test, would have caught it. For anything that serialises to a format with its own encoding rules, test at the format boundary, not the string boundary.

Assert the cross-engine-sensitive value. The Playwright suite already ran webkit. The failure was assertion depth, not breadth. Adding a webkit browser to CI without also asserting the values that diverge between engines is coverage theatre. Running webkit and not checking the clock text content didn’t catch the clock regression.

Reach for formatToParts first. If I’d designed the clock from the constraint “this must produce identical output in V8 and WebKit,” I’d have found formatToParts on the first pass. I reached it after a production crash instead. The API exists to avoid engine-specific format string shapes; it’s the right default for any locale-formatted date that will be parsed.

What shipped

Two fixes on the same day. '\\;' in the stream ICS serialiser, and the hub clock rebuilt from formatToParts. The streams page also picked up a copy correction (Logic Pro and Reason added to the music stream tools listing) and day-off art on the Off card, rotated by date. A code-simplifier pass collapsed the TBC copy into a single TBC_COPY constant in src/data/stream-kinds.ts; it had been defined twice. Out of scope: broader ICS format compliance beyond the separator fix.

Both bugs are in the build log. The lesson from the semicolon fix: when the test author and the implementation author are the same person, read the output file before you merge.

All writing →