fix(build): make bundle:ui build what actually ships, and stop verify clobbering it

Closes #132.

`npm run bundle:ui` bundled src/ui/App.jsx with --global-name=PearCalApp and a
manual JSX factory. Everything that actually ships - the CLAUDE.md Android and
iOS commands, release.sh, and therefore the committed assets/app-ui.bundle -
bundles src/ui/main.jsx as a plain IIFE with automatic JSX. Both wrote to the
same path, so running the documented command replaced the shipping bundle with
a different artifact that has no entry point wired, and git status said only
"modified". It bit twice on 2026-07-23.

Measured before and after rather than assumed:

  old bundle:ui       784,534 bytes   (App.jsx + global name, no entry point)
  what ships        1,414,532 bytes
  new bundle:ui     1,414,532 bytes   md5 5484d83d… - identical, git sees no change

The quoting is the part worth not repeating. Writing the define as
--define:process.env.NODE_ENV=\"production\" inside a package.json script does
NOT survive: npm hands the line to sh, sh strips the bare double quotes, and
esbuild receives the identifier `production` rather than the string. That
produced a third distinct artifact - correct entrypoint, wrong output. Single
quotes around the escaped pair are what make it reach esbuild intact.

Also split the verify gate off the shipped artifact entirely. `npm run verify`
now builds the UI to node_modules/.cache (gitignored, so it can never surface
in git status) purely to prove it compiles. A gate that rewrites a tracked
build output is the other half of what made this bug invisible.

Deleted assets/index.html, the only consumer of window.PearCalApp and the one
thing that made the old form look load-bearing. It is dead: the mobile WebView
builds its own HTML inline in app/index.tsx and injects the bundle, which
self-mounts via main.jsx's createRoot; the Electron renderer has its own
index.html that never mentions PearCalApp; and nothing in the repo, any build
script or app.json references the file. It had not been touched since the
initial commit.

release.sh keeps its inlined bundle commands - what a release builds should be
visible where it runs - but its comment claiming bundle:ui differs is no longer
true, so it now says what is actually the case. No release command changed.

Verified: `npm run bundle:ui` reproduces the committed bundle byte-for-byte,
and `npm run verify` leaves assets/ untouched. 266 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEd2k9F1HgR3JHdB8spury
This commit is contained in:
Your Name
2026-07-26 15:13:13 -05:00
co-authored by Claude Opus 5
parent 1149d8a99e
commit 3d15740b59
3 changed files with 9 additions and 93 deletions
-85
View File
@@ -1,85 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#111111" />
<title>PearCal</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%; width: 100%;
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="app.bundle.js"></script>
<script>
// ── IPC bridge (WebView → RN → Bare) ────────────────────────────────────
let _nextId = 1
const _pending = new Map()
window.__pearDB = {
call (method, ...args) {
return new Promise((resolve, reject) => {
const id = _nextId++
_pending.set(id, msg => {
if (msg.error) reject(new Error(msg.error))
else resolve(msg.result)
})
window.ReactNativeWebView.postMessage(JSON.stringify({ id, method, args }))
})
}
}
// Responses from RN → WebView
window.__pearResponse = function (msg) {
const resolve = _pending.get(msg.id)
if (resolve) { _pending.delete(msg.id); resolve(msg) }
}
// Events from RN → WebView (P2P sync etc.)
window.__pearEvent = function (event, data) {
window.dispatchEvent(new CustomEvent('pear:' + event, { detail: data }))
}
// ── DB proxy object passed as props to App ───────────────────────────────
const db = {
getProfile: () => window.__pearDB.call('getProfile'),
updateProfile: (u) => window.__pearDB.call('updateProfile', u),
listEvents: (opts) => window.__pearDB.call('listEvents', opts),
putEvent: (ev) => window.__pearDB.call('putEvent', ev),
deleteEvent: (d, id) => window.__pearDB.call('deleteEvent', d, id),
listGroups: () => window.__pearDB.call('listGroups'),
putGroup: (g) => window.__pearDB.call('putGroup', g),
deleteGroup: (id) => window.__pearDB.call('deleteGroup', id),
listMembers: (gid) => window.__pearDB.call('listMembers', gid),
putMember: (gid, m) => window.__pearDB.call('putMember', gid, m),
removeMember: (gid, mid) => window.__pearDB.call('removeMember', gid, mid),
}
const notifs = {
scheduleForEvent: (ev) => window.__pearDB.call('scheduleForEvent', ev),
cancelForEvent: (id) => window.__pearDB.call('cancelForEvent', id),
restoreAll: () => window.__pearDB.call('restoreAll'),
}
const sync = {
joinGroup: (g) => window.__pearDB.call('joinGroup', g),
leaveGroup: (id) => window.__pearDB.call('leaveGroup', id),
putEvent: (gid, ev) => window.__pearDB.call('putEvent:sync', gid, ev),
deleteEvent: (gid, id, d) => window.__pearDB.call('deleteEvent:sync', gid, id, d),
putGroup: (g) => window.__pearDB.call('putGroup:sync', g),
}
// ── Mount React app ──────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(React.createElement(PearCalApp.default, { db, notifs, sync }))
</script>
</body>
</html>
+3 -2
View File
@@ -7,9 +7,10 @@
"android": "expo run:android",
"ios": "expo run:ios",
"bundle:bare": "bare-pack --linked --defer fs --defer path src/bare.js -o assets/bare-universal.bundle",
"bundle:ui": "esbuild src/ui/App.jsx --bundle --format=iife --global-name=PearCalApp --jsx-factory=React.createElement --jsx-fragment=React.Fragment --outfile=assets/app-ui.bundle",
"bundle:ui": "esbuild src/ui/main.jsx --bundle --format=iife --jsx=automatic --define:process.env.NODE_ENV='\"production\"' --outfile=assets/app-ui.bundle",
"test": "node --test test/*.test.js",
"verify": "npm test && npm run bundle:bare && npm run bundle:ui",
"bundle:ui:check": "esbuild src/ui/main.jsx --bundle --format=iife --jsx=automatic --define:process.env.NODE_ENV='\"production\"' --outfile=node_modules/.cache/pearcal-ui-verify.bundle",
"verify": "npm test && npm run bundle:bare && npm run bundle:ui:check",
"postinstall": "patch-package"
},
"author": "Holepunch",
+6 -6
View File
@@ -1198,12 +1198,12 @@ _confirm "app.json version looks correct — proceed with bundle builds?"
# Constitution §5 gate: the unit tests run first and a red suite aborts the
# release via set -e, then every bundle the release ships is rebuilt.
#
# The bundle commands are inlined rather than delegated to `npm run verify`
# because package.json's bundle:ui still points at the old src/ui/App.jsx
# entrypoint with the manual JSX factory, while the release has been shipping
# src/ui/main.jsx with automatic JSX. Calling verify here would silently change
# the shipped UI bundle. Reconciling package.json is tracked separately; a
# consistency pass is the wrong place to change an artifact.
# The bundle commands stay inlined rather than delegated to `npm run verify`,
# so what a release builds is visible right here rather than one indirection
# away. They are no longer DIFFERENT, though: as of #132, package.json's
# bundle:ui builds this exact command and reproduces the shipped bundle
# byte-for-byte, and `npm run verify` no longer writes to the tracked artifact
# at all (it builds the UI to a throwaway path purely to prove it compiles).
# ---------------------------------------------------------------------------
echo "==> Running unit tests..."
npm test