Skip to content

perf(build): free 2,972 gzip bytes in main.js for the v3 redesign - #439

Merged
chhoumann merged 13 commits into
masterfrom
perf/bundle-room
Oct 13, 2026
Merged

chhoumann merged 13 commits into
masterfrom
perf/bundle-room

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

PodNotes users see no change. The production main.js shrinks from 96,076 to 93,104 gzip bytes, and from 301,276 to 292,929 raw bytes, against master 921be0d (npm run size, gzip level 9). Master sits 3,924 bytes under the 100,000-byte gate, and the UI0 shell (#434) alone adds about 5,600, so the v3 redesign needs this room.

Only build configuration changes. The output syntax stays ES2020, as on master. Each row shows what removing that cut alone from the final configuration costs.

Cut Gzip bytes Raw bytes Risk Evidence
Run terser before the Oxc minifier 1,638 3,468 A minifier transform changes behavior Smoke results identical to master, screens pixel-identical, e2e passes
Drop Svelte's hydration code in production builds 1,289 3,471 A Svelte upgrade sets hydrating outside hydrate() Audit of every set_hydrating(true) in Svelte 5.57.1; the build fails if the module changes shape or moves
pn- scoped CSS prefix instead of svelte- 127 1,308 User CSS that targets svelte-<hash> classes, which change with every style edit anyway Screens pixel-identical
Skip Svelte's window.__svelte version registration 35 102 None; nothing in Obsidian reads it

Terser alone and Oxc alone produce almost the same size. Run in sequence, terser inlines single-use functions and variables that Oxc keeps, and Oxc then mangles and compresses the result. pure_getters stays off, because reading a Svelte state property subscribes to it. Vite drops legal comments once build.minify is set, so legal: true keeps the Fuse.js Apache-2.0 notice that master ships.

PodNotes mounts its components and never hydrates server-rendered markup. Svelte restores its hydrating flag from values it read earlier, so the minifier cannot prove the flag is always false. scripts/svelte-without-hydration.ts pins it in production builds, and fails the build if Svelte moves or reshapes the flag.

New dev dependencies: terser, and acorn, which the lockfile already had. package-lock.json gains only terser and its five dependencies. The unused Vite banner is gone; production bundles never carried it.

Syntax floor

The target stays es2020, now shared with the Oxc minifier so it cannot raise the syntax either. A new build check, scripts/syntax-floor.ts, parses the final main.js as ES2020 with acorn, rejects regular expression lookbehind, and fails before writing the file otherwise. A tooling test builds a class field through Vite and checks that no main.js is written.

  • Android: the Obsidian 1.14.4 APK requires Android 6.0 and sets no WebView minimum, and Obsidian's own Android app.js parses as ES2020. Master and this branch's main.js both parse on V8 8.4 (Node 14.21.3). An ES2021 target emitted logical assignment (??=), which V8 8.4 rejects, so this branch keeps ES2020.
  • iOS: the App Store listing requires iOS 15.6. Safari 15 mis-scopes some class field initializers (WebKit bug 236843, fixed in Safari 16) and lacks lookbehind (Safari 16.4). ES2020 output keeps class fields lowered, and the check rejects both.
  • Desktop: Obsidian's update feed requires installer 1.1.9 or later, which ships Electron 21 (Chromium 106).

Composition against master 921be0d

Raw bytes and marginal gzip bytes (the gzip size of the whole bundle minus the bundle without that part).

Part Raw before Raw after Gzip before Gzip after
src outside src/ui 156,225 154,398 50,561 49,163
src/ui 94,866 90,022 27,743 26,548
Injected Svelte CSS (part of src/ui) 23,408 22,616 5,269 5,174
Svelte runtime 49,599 47,902 18,318 17,348

Room left for later

  • Legacy Svelte runtime: once UI4 moves every component to runes, legacy_mode_flag becomes false and its branches can go.
  • Newer output syntax once Obsidian sets a WebView floor of Chromium 85 or later (logical assignment, about 110 bytes) and an iOS floor of 16 (native class fields, about 1,160 bytes).
  • Whitespace in the injected CSS that Svelte copies verbatim: about 140 bytes, with a CSS tokenizer in the existing preprocess step.

Verification

Node 22.23.2, npm 10.9.8, Linux. Head d438bf7, merged with master 921be0d.

  • npm run lint, npm run format:check, npm run typecheck, npm run build, npm run test (107 files, 1,980 tests), npm run test:tooling (11 tests), npm run docs:build: all pass.
  • npm run size: raw 292,929 of 330,000; gzip 93,104 of 100,000.
  • npm run test:e2e on Obsidian 1.14.4: 13 files, 89 tests passed. An earlier head of this branch also passed on Obsidian 1.13.7, the minAppVersion floor.
  • The syntax floor check fails the build at the first ??= when the target is raised, and its tests fail when it parses at ES2021.
  • At the previous head (b11c8cb, against master e3b90c1), in isolated Obsidian 1.14.4 on fresh vaults with the fixture feed:
    • User path (add a feed in Settings, fuzzy episode search, play, capture a timestamp with a misspelled tag, create an episode note, open the timestamp link in Reading view, open the direct obsidian://podnotes URI): results identical.
    • Settings, grid, episode list, player, and the mobile-emulation window: all five pixel-identical.
    • Under app.emulateMobile(true): PodNotes loads, its settings tab opens, the episode plays and pauses, and the results match master.
    • dev:errors stayed empty in every run.
    • Cold load time, from the PodNotes row of Obsidian's Startup time report, sides interleaved: median of 5 cold loads 11.9 ms on master and 11.7 ms on the branch (p = 0.60); on the older base edb9447, 4.0 and 3.5 ms (8 rounds, p = 0.32). No measurable change. Master's own cold load rose from about 4 ms to about 12 ms when fix(feeds): keep private subscriptions working across devices #428 merged, which this PR does not touch.

Release impact

None. No settings, storage, API, or URI change.

Docs at release

None.

Note

Shrink main.js by 2,972 gzip bytes with Terser, hydration removal, and ES2020 syntax floor

  • Adds a Vite plugin in svelte-without-hydration.ts that pins Svelte's hydration flag to false and no-ops its setter, so minification can drop hydration branches; the build fails if Svelte's module shape changes
  • Adds an Acorn-based validator in syntax-floor.ts plus a Vite generateBundle plugin that fails the build when any chunk uses syntax above the ES2020 / Safari 15 floor (e.g. class fields, regex lookbehind)
  • Rewrites vite.config.ts to minify with Terser (no pure property reads, no mangling during build.minify), remove console calls, mangle names, disable Svelte version disclosure, and change Svelte CSS hash prefixes from svelte- to pn-
  • Adds unit and real-build tests in syntax-floor.test.ts and tests/tooling/syntaxFloor.test.ts; acorn and terser are now declared dependencies
  • Behavioral Change: emitted CSS class names now use the pn- prefix instead of svelte-, the custom bundle banner is gone, and production hydration code is removed; any out-of-tree code matching on svelte- class names or the banner breaks

Macroscope summarized d438bf7.

Svelte 5's runtime uses private fields and methods. At the es2020 target
the bundler lowers each one to WeakMap helpers and every class field to a
defineProperty call. Emitting ES2022 keeps them native and frees 1,351
gzip bytes (90,884 to 89,533).

Saving settings already needs structuredClone (Chromium 98, Safari 15.4),
which postdates ES2022 class fields, private methods and logical
assignment, the only syntax this adds. Desktop installers from 1.1.9 run
Electron 21, and the iOS app requires iOS 15.6.
PodNotes mounts its components and never hydrates server-rendered
markup, so Svelte's hydrating flag never becomes true. Svelte restores
the flag from values it read earlier, which the minifier cannot prove
are false, so every hydration branch shipped. A build plugin pins the
flag to a constant and fails the build if Svelte changes its shape.

Frees 1,194 gzip bytes (89,533 to 88,339).

Svelte 5.57.1 audit: hydrate() is the only unconditional
set_hydrating(true), and the bundle does not include it. Every other
call restores a value read from the flag, or runs inside if (hydrating).
Svelte adds its major version to window.__svelte.v so tools can see which
versions a page runs. Nothing in Obsidian reads it. Frees 27 gzip bytes
(88,339 to 88,312).
Every scoped element and selector carries the component's CSS hash class,
324 times in the bundle. A pn- prefix instead of svelte- saves 4 bytes
each and keeps PodNotes' style ids apart from other Svelte plugins'.
Frees 70 gzip bytes (88,312 to 88,242).
Terser inlines single-use functions and variables that Oxc keeps, and
Oxc then mangles and compresses its output. Either minifier alone gives
about the same size; together they free 1,712 gzip bytes (88,242 to
86,530).

pure_getters stays off because reading a Svelte state property
subscribes to it. Terser's own mangling is off because Oxc mangles
afterwards, and mangling twice compressed slightly worse.

package-lock.json gains only terser and its five dependencies; npm 10.9
dropped unrelated libc fields, which were restored.
Safari 15 mis-scopes a class field initializer that references an outer
variable when the initializer contains a parenthesized expression
(WebKit bug 236843, fixed in Safari 16). Obsidian evaluates plugins
inside a function, so every class in main.js is affected, and the iOS
app still supports iOS 15.6. Today's bundle has no such initializer, but
an es2022 target would emit one silently after any future change. It
would also keep class static blocks, which need Safari 16.4.

Targeting Chromium 98 and Safari 15.6 lets the compiler lower class
fields and private members for Safari 15. Saving settings already needs
structuredClone, so Chromium 98 is the Android floor. This gives back
1,163 gzip bytes (88,881 to 90,044 after merging master).

The hydration transform now runs only in production builds, because it
returns no source map and development builds keep theirs.
Vite drops legal comments once build.minify is set, so running terser
removed the Fuse.js Apache-2.0 notice that master ships. Keeping legal
comments restores it for 135 gzip bytes (90,044 to 90,179).

The hydration plugin now fails the build when Svelte moves its
hydration module, instead of silently shipping the hydration code again.
Lowering class fields for Safari 15 depends on Oxc's compat table, so a
toolchain bump or an es2022 target could ship them to the iOS 15.6 app
again. The build now parses main.js as ES2021 with acorn, already in
the lockfile, and fails before writing it otherwise. The Oxc minifier
shares the build target, so neither producer can raise the syntax.

The Vite banner is gone: it is a plain comment, so production builds
never shipped it. The production bundle is byte-identical.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: d438bf7
Status: ✅  Deploy successful!
Preview URL: https://ea9579de.podnotes.pages.dev
Branch Preview URL: https://perf-bundle-room.podnotes.pages.dev

View logs

Safari 15 also lacks regular expression lookbehind, which compilers
cannot lower, and an ES2021 parse accepts it. The floor check now
rejects it and names Safari 15 in its error.

A tooling test builds a class field through Vite and checks that the
build fails before main.js is written, so moving the check to a later
hook fails the test. It lives with the other tooling tests because
Vite reads window while loading, which the unit suite forbids.
Obsidian's Android app sets no WebView minimum, and its own code parses
as ES2020. The chrome98 target emitted ES2021 logical assignment, which
V8 8.4 rejects, so PodNotes would fail to load where master loads. The
structuredClone dependency only breaks saving, not startup, so it is no
floor.

The target is es2020 again, which also keeps class fields lowered for
Safari 15. The syntax floor check parses main.js as ES2020 and still
rejects regex lookbehind. This gives back 107 gzip bytes.
@chhoumann chhoumann changed the title perf(build): free 3,062 gzip bytes in main.js for the v3 redesign perf(build): free 2,972 gzip bytes in main.js for the v3 redesign Oct 13, 2026
@chhoumann
chhoumann merged commit e484498 into master Oct 13, 2026
16 checks passed
@chhoumann
chhoumann deleted the perf/bundle-room branch October 13, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant