Skip to content

refactor: cut main.js by 1,739 gzip bytes without behavior change - #501

Merged
chhoumann merged 58 commits into
masterfrom
refactor/size-headroom
Oct 15, 2026
Merged

chhoumann merged 58 commits into
masterfrom
refactor/size-headroom

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

main.js was 102,916 gzip bytes on master, against the 103,000 budget. That left too little room for the bug fixes in flight. This PR cuts the bundle to 101,173 bytes (-1,743) with refactors that keep behavior the same. Headroom grows from 84 to 1,827 bytes. The budget does not change.

Change Commits Gzip bytes
Svelte compile cost. The only rune-less component (PlaylistsPage) kept Svelte's legacy runtime in the bundle. The only use: action and the only two class={expr} attributes pulled in the action runtime and clsx. 5 -723
Repeated shapes merged into one helper or table: collection decoders, template tags, network snapshots, content types 18 -343
Dead branches and guards that callers or earlier checks already rule out 15 -296
Class members that read no instance state, moved to module functions and constants. The minifier cannot shorten member names. 6 -240
Private copies of helpers the bundle already has 6 -141

The evidence archive has the per-commit table with the reason each number moved, the rejected candidates, the V-501 fixes, the bundle analysis, logs, contact sheets, and the scripts to rerun every measurement. Each commit was built in order, and the deltas sum to the total. The numbers in the commit bodies come from each explorer's own branch and do not chain.

Behavior that stays the same: user-facing and warning strings, data.json, src/API, obsidian://podnotes URIs, the DOM, class names, and accessible names. The ES2020 floor and the tagged-template rule hold. The files that other open units change are untouched.

Three candidates were dropped because they changed behavior. TimerNotice would show 00:00:00 instead of a negative time after a clock step. The independent verification V-501 found the other two, which are reverted here with regression tests. getPodcastSegmentFormatted lost the numeric strings JavaScript workflows pass, and saving dropped a valid mediaType or isVirtual that an object inherits or hides from enumeration.

Verification

Node 22.23.2, Obsidian 1.14.4 on Linux.

  • npm run lint, format:check, typecheck, build, test (126 files, 2,766 tests, both Svelte checks with 0 errors and 0 warnings), test:tooling (5 files, 35 tests), and docs:build (18 pages) pass. npm run size reports 101,173 gzip and 313,325 raw. Five builds gave identical bytes.
  • The two regression tests fail at 3652136, pass on master, and pass after the reverts. Live in Obsidian, the API again returns 00:01:55-00:02:05 for numeric-string times.
  • npm run test:e2e passed on a fresh vault, 32 files and 234 tests. After the reverts, the ten e2e files that cover capture, playback, local files and saving passed on a fresh vault, 91 tests.
  • The pixel pass covered every verify state at 320, 820, and 390 px in light and dark. All 142 shots are identical to master at threshold 0, and two master runs were also identical. An acorn parse of both bundles finds the 17 injected styles and 117 static HTML literals identical to master.
  • Two reviewers, Claude Fable 5.1 and GPT-6 Astra, found no behavior change. Fable's one warning is fixed, and videoStage is now a real attachment. V-501 then found the two changes above.

Release impact

None. No settings, storage, API, URI, or migration changes. No docs changes at release.


Note

Medium Risk
Most changes are mechanical DRY refactors, but persistence decode/encode paths, network option validation, and player initialization were touched alongside noted edge-case behavior shifts (e.g. invalid playlist data, default volume clamping).

Overview
This PR shrinks main.js by ~1.7k gzip through refactors that mostly preserve behavior, while fixing two regressions caught in review.

Bundle / UI compile path: PlaylistsPage opts into Svelte runes so the legacy runtime can drop out of the bundle. The video stage moves from a use: action to {@attach player.videoStage}, which now returns a teardown function instead of { destroy() }. A few components switch to rune-style patterns (class:, $effect cleanup in the queue drag handler, shared followResizes for layout observers).

Shared helpers replace copy-paste: Vault note creation uses a single createFileIfNotExists (feed and episode notes). Template engines gain joinLines, addEpisodeDateTags, durationTag, and a unified timeTag for timestamp templates. Persistence decoders share decodeRecord / decodeNormalized; queue/favorites/local files pull defaults from exported PlaylistSettings constants.

Structural refactors: FeedDocumentParser becomes module functions; PlayerController and TranscriptionService hoist instance-free logic to module scope; URI handling drops redundant segment start checks and reuses urlCandidates.

Explicit fixes (not pure size cuts): Tests lock in getPodcastSegmentFormatted accepting numeric strings from JS workflows, and persistence encode keeping inherited/hidden mediaType / isVirtual on episodes and playlists.

Reviewed by Cursor Bugbot for commit 1c72ce1. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Refactor PodNotes utilities and services to cut main.js by 1,771 gzip bytes

  • Consolidates duplicated logic into shared helpers: createFileIfNotExists for vault file creation (createFileIfNotExists.ts), decodeRecord for collection decoding (collectionCodecs.ts), decodeNormalized for settings normalization (settingsCodec.ts), joinLines and durationTag in template engines (TemplateEngine.ts), and followResizes for resize observers (layout.ts)
  • Extracts class methods to module scope in PlayerController.ts, TranscriptionService.ts, and feedDocumentParser.ts, preserving existing calculations and error behavior
  • Compiles PlaylistsPage.svelte in Svelte runes mode and migrates the video stage from use: actions to teardown callbacks to avoid keeping legacy runtime in the main bundle
  • Behavioral Change: PlayerController no longer clamps the initial snapshot defaultVolume to 0–1; network request/response headers and options are rejected unless they are plain objects; playlist and settings decoding returns empty episode lists instead of restoring fallback episodes when persisted values are invalid; downloadFile results no longer expose byteLength; notice updates clear the notice body before rendering; detectAudioFileExtension treats missing mask bytes as 0xff

Macroscope summarized 1c72ce1.

A promise settles once, so the settled flag and the aborted re-checks were dead: the entry assertActive rules out an earlier abort, and the abort listener runs before any later settle.

gzip 102900 -> 102866 (-34)
… constants

As instance fields their long names stayed in the bundle at every use; as constants the minifier inlines the numbers.

gzip 102866 -> 102793 (-73)
None of them reads instance state. As private methods their names stayed in the bundle at every this.<name> call; as functions the minifier shortens or inlines them. FeedDocumentParser keeps parseEpisodes, its one public method.

gzip 102793 -> 102710 (-83)
savedProgress, resumeTime and startDuration read only the played-episodes store, not the controller. As functions the minifier shortens their names at each call.

gzip 102710 -> 102675 (-35)
Seven checks spelled out the two statuses that isPlaying already names.

gzip 102675 -> 102658 (-17)
getEpisodeAudioBuffer reaches readVaultAudio and getAudioContainerHint only after it has checked that the episode, and a reused download, are audio, so the hint branches never saw another value.

gzip 102653 -> 102626 (-27)
Every update replaces the notice body.

gzip 102612 -> 102606 (-6)
It reads no service state.

gzip 102606 -> 102590 (-16)
A missing mask is a full-byte mask, so one comparison covers both kinds of signature.

gzip 102590 -> 102571 (-19)
Both requests go to the same host, so fetchShows takes the endpoint and its query parameters instead of each caller appending them one by one.

gzip 102571 -> 102537 (-34)
The settings codec clamps defaultVolume to a finite 0 to 1 when it reads data.json or an import, and setVolume is the only other writer and clamps too, so the constructor's own clamp never changed the value.

gzip 102537 -> 102523 (-14)
Ten reads of this.lifetimeAbortController.signal, a property chain the minifier cannot shorten, become this.signal.

gzip 102523 -> 102512 (-11)
stop() runs once because stopTime is set once, clearTimeout ignores an absent id, and one render() replaces two one-caller helpers.

gzip 102512 -> 102499 (-13)
PlaylistsPage was the only component without a rune, so Svelte compiled it in legacy mode, and the legacy-mode flag kept every legacy branch of the Svelte runtime in main.js.

gzip 102762 -> 102246 (-516)
A class={expression} attribute makes Svelte resolve it with clsx, for class arrays and objects. The back button's two classes become class directives, and the mini player's progress bar builds its class from a suffix, so clsx leaves main.js.

gzip 102222 -> 102085 (-137)
The handler already refuses a negative start time before it parses the
segment end, so parseSegmentEndTime never saw one.

gzip 102900 -> 102889 (-11)
The five collection decoders each repeated the same object guard, entry
loop, skip warning and path building. decodeRecord owns that shape; each
decoder keeps only its per-entry fields. Warning text is unchanged.

gzip 102889 -> 102819 (-70)
…s empty

Every decodePlaylist fallback is a built-in or new playlist with no
episodes, so the fallback episode decoding and clonePlaylist only ever
produced an empty list. The fallback is now the playlist's settings, and
the unused default parameters are gone.

gzip 102819 -> 102775 (-44)
The description, content and chapters tags each split, prefixed and
joined their lines by hand. joinLines does it once.

gzip 102775 -> 102734 (-41)
NoteTemplateEngine repeated the date, currentdate and episodenumber tags
of the file-name engines, and the transcript engine repeated its duration
tag. Both now share addEpisodeDateTags and durationTag, in the same tag
order.

gzip 102734 -> 102729 (-5)
time, linktime, segment and linksegment differed only in linkify and
whether a segment applies.

gzip 102729 -> 102721 (-8)
… notes

createFeedNote carried its own copy of the folder creation and the
racing-create recovery. Both note creators now use the utility, and the
feed note keeps its re-check before it creates.

gzip 102721 -> 102688 (-33)
snapshotOptions and the two response snapshots each spelled out one
Reflect.get per field. readFields reads the same fields in the same
order, once each.

gzip 102688 -> 102638 (-50)
The playback rate, episode list limit and skip lengths each checked for a
normalized value in their own way. decodeNormalized applies the one rule:
warn when a stored value differs from its normalized number. Warning
order is unchanged.

gzip 102638 -> 102598 (-40)
A piece containing a dot already fails the hextet pattern, and both
branches build exactly eight hextets once the length checks pass.

gzip 102598 -> 102588 (-10)
The available and placeholder branches built the same entry twice.

gzip 102542 -> 102530 (-12)
bind wrapped subscribe, apply and shouldPersist in lambdas that only
forwarded their arguments to erase the value type. A single cast erases it.

gzip 102530 -> 102512 (-18)
new RegExp(literal) copied the literal on every call.

gzip 102512 -> 102498 (-14)
A lookup table and a shared regex only ever mapped ( and ) to %28 and %29.

gzip 102498 -> 102474 (-24)
findUniqueTitleMatch kept each candidate's best score but only ever asked
whether exactly one candidate reached the threshold.

gzip 102474 -> 102444 (-30)
formatSeconds already renders a negative or non-finite time as zero.

gzip 102444 -> 102438 (-6)
…y make

A null file is not a TFile, and undefined chapters are not an array.

gzip 102438 -> 102430 (-8)
truncateToLimit already returns a name that fits unchanged, and the
platform limit differs only in its mode.

gzip 102430 -> 102408 (-22)
U+200B through U+200F are contiguous, so five equality checks are one
range check.

gzip 102408 -> 102400 (-8)
Each refused domain matched itself and its subdomains with its own pair of
checks.

gzip 102400 -> 102392 (-8)
The rename handler kept its own copy of mapRecord. Both stores it writes
hold null-prototype records either way.

gzip 102392 -> 102358 (-34)
…headers

Three spelled-out plain-object checks and two null-prototype records
matched the shared helpers exactly.

gzip 102358 -> 102338 (-20)
Each entry spelled out pattern and extension keys around the same
regex and extension.

gzip 102338 -> 102306 (-32)
The queue, favorites and local files stores restated the names, icons and
flags that constants.ts already defines for those playlists.

gzip 102306 -> 102281 (-25)
getUrlExtension only returns letters and digits, so escaping the template
extension before stripping it changed nothing.

gzip 102281 -> 102256 (-25)
The decoded copy already holds a valid mediaType or isVirtual, so
assigning it again changed nothing. Invalid and missing values are still
removed, with the same warnings.

gzip 102256 -> 102233 (-23)
The error constructor, accepted statuses and response snapshot each
spelled out the same 100 to 599 safe-integer check.

gzip 102233 -> 102223 (-10)
videoStage returned an action's { destroy } that the template adapted for {@Attach}. It now returns its teardown, so Player attaches it directly and the doc comment names what it is.

gzip 101161 -> 101145 (-16)
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1c72ce1
Status: ✅  Deploy successful!
Preview URL: https://a621cad8.podnotes.pages.dev
Branch Preview URL: https://refactor-size-headroom.podnotes.pages.dev

View logs

JavaScript workflows pass numeric strings to getPodcastSegmentFormatted, and episode objects can carry mediaType or isVirtual through a prototype or a non-enumerable property. Both fail at 3652136 (V-501).
@chhoumann chhoumann changed the title refactor: cut main.js by 1,771 gzip bytes without behavior change refactor: cut main.js by 1,739 gzip bytes without behavior change Oct 15, 2026
@chhoumann
chhoumann merged commit 51d83ea into master Oct 15, 2026
20 checks passed
@chhoumann
chhoumann deleted the refactor/size-headroom branch October 15, 2026 16:12
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