Skip to content

perf: runtime re-render, font and first-visit fixes for web and mobile - #14677

Merged
dylanjeffers merged 6 commits into
mainfrom
claude/clever-edison-u4bbxy
Oct 21, 2026
Merged

dylanjeffers merged 6 commits into
mainfrom
claude/clever-edison-u4bbxy

Conversation

@dylanjeffers

@dylanjeffers dylanjeffers commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to the bundle/startup series (#14660–#14673). That series was about bundle size; this one is about runtime work on web and mobile: re-renders, request waterfalls, polling, fonts and image sizes. One commit per area. Everything is JS-only and OTA-safe.

1. Web: fonts

Web shipped Avenir twice as base64 CSS, about 240 KB each time: once through harmony's avenir.css in the entry CSS, and again in public/fonts.css.

  • Separate woff2 files: public/fonts.css now points at six woff2 files, the same bytes decoded from the base64, with font-display: swap.
  • Preloads: index.html preloads Medium, DemiBold and Bold, about 90 KB.
  • Inlined copy removed from web only: a Vite plugin drops harmony's inlined copy from the web build. Web imports harmony from source, so the plugin covers both src/.../avenir.css and dist/harmony.css. Other harmony consumers are unchanged.
  • Landing hero font: the 2026 landing hero waits on Dust Bucer, which now loads as woff2: 284 KB → 111 KB. Outlines, kern and GPOS are identical; only hinting is dropped.
  • Result: entry CSS goes from 379 KB / 212 KB gzip to 137 KB / 27 KB gzip. fonts.css goes from 242 KB to 1.3 KB.

2. Web: app shell and play bar

  • App shell: the playback rate and position hooks moved from WebPlayer into a leaf PlaybackPersistence. Play/pause, track changes and the 1s podcast position ticks no longer re-render every route, the nav and the modals.
  • Podcast position saves: localStorage writes are batched to one per 5s, and flushed on pause, track change, pagehide and hidden tab.
  • Desktop PlayBar and NowPlaying: they re-render only when the displayed second changes. Before, they re-rendered every 500ms forever and re-bound the hotkeys each time.
  • Mobile-web PlayBar: it used to create a new interval on every render. The progress bar now owns its own interval.

3. Web: first visit

PersistGate held the whole app until a dynamic localforage import and an IndexedDB read finished, for every new visitor.

  • Fix: it checks indexedDB.databases() first and skips localforage when that database doesn't exist.

4. Web + mobile: lineups

  • togglePlay: reads playback state at press time, so play/pause/skip no longer rebuild every lineup tile. Tiles already select their own playing state.
  • Lineup hooks: they memoize trackIds, and the new useLoadNextPage keeps loadNextPage stable. This also stops the load-more IntersectionObserver being recreated on every render.
  • Library: library selectors use createSelector; the library page had been re-rendering on every redux action.
  • Smaller fixes:
    • useToggleTrack (explore tiles, chat unfurls) selects a single boolean.
    • Mobile lineups use windowSize 11.

5. Shared data hooks

  • useCurrentAccount: placeholderData used to re-parse the cached account from localStorage in every observer on every render while the account loaded, and for the whole session when signed out. The parsed result is now reused until the stored strings change.
  • Batched entity hooks: useTracks, useUsers and useCollections memoize their queries array.
  • Track page lineup: the remix parent/remixes, more-by-artist and trending requests run in parallel (3 round trips → 1). Section order and dedupe are unchanged.
  • Track rank: trending IDs use a 30-minute staleTime instead of 5 minutes.
  • Profile page: an artist's reposts are fetched only when the Reposts tab opens. Until the profile is known, reposts still load in parallel.
  • Mobile:
    • the offline-downloads persist is throttled to 1s
    • ChatScreen no longer renders every cached message on mount

6. Mobile: audio queue and images

  • Queue URL checks: starting playback used to check the stream and 1000px artwork of every queued track against mirrors, about 2N HEAD requests, competing with the track that was buffering.
    • Now only the start track, the one before it and the next 2 are checked. The rest, and appended tracks, use their primary URLs.
    • If a primary URL fails, the existing PlaybackError handler re-resolves that track and skips the primary.
    • Offline and gated tracks are unchanged.
  • useImageSize: reads preloadImageFn from a ref, so inline callers no longer re-run the effect and prefetch on every render. A cached larger image is used only up to 3.5× the requested width.
  • Artwork sizes:
    • the collapsed Now Playing drawer loads 480px artwork, and 1000px once opened
    • feed collection tiles use 150px, like track tiles

Not done

  • Collection page virtualization: the virtualized table turns off column sorting, and that page needs sorting and drag-reorder.
  • Removing the JS image preload on web: only useProfilePicture wires onError, so the preload is the only mirror fallback for track and collection art.
  • tanstack focusManager/onlineManager on mobile: the retryer pauses while unfocused, so a failed fetch during background playback or CarPlay would hang.
  • useHotKeys throttle: a throttle that actually applied would skip preventDefault on dropped keydowns, so the page would scroll.
  • Drawer gestures on reanimated, and reanimated scroll handlers: these are bigger refactors.

Test plan

  • tsc for common, web and mobile; eslint on changed files
  • common vitest: api and useImageSize, 34 passed
  • web vitest: persistStorage, lineup, library, collection, track, accessibility
  • mobile jest: 64/64 tests pass. App-test.tsx fails to resolve ~/api/tan-query/utils, and fails the same way on main.
  • Prod web build: no Avenir @font-face left in any CSS asset
  • Fonts:
    • Avenir renders on web and in the desktop app with no visible flash on a cold load
    • the landing hero appears quickly
  • Desktop play bar:
    • elapsed time ticks
    • scrubbing while paused updates the time
    • ←/→ prev/restart, and ±15s on podcasts
    • space toggles play
  • Mobile web: play bar progress moves, freezes on pause and resets on track change. Podcast resume after a reload lands within about 1s.
  • Lineups: play/pause tiles on trending, feed, profile and library, on web and mobile. The highlight and speaker icon follow, and infinite scroll still loads.
  • Explore and chat track tiles: play state is correct
  • Library: a removed item disappears; the All category merges reposts, favorites and purchases
  • Track page: more-by, remixes and recommended sections are correct for a remix, a track with remixes and a track with none
  • Profile page:
    • artist: Tracks loads, Reposts loads on click, and /handle/reposts deep links
    • non-artist: Reposts shows
  • First visit: a fresh profile renders; an existing user's search history survives
  • Mobile playback:
    • start a long lineup or 500+ track playlist: playback is fast and auto-advances
    • rapid skips; shuffle; repeat all and repeat one
    • load more while the last track plays
    • offline collection in airplane mode
    • gated and purchased tracks, and previews
    • lock-screen artwork updates
  • Mobile Now Playing: opening the drawer sharpens the artwork without a flash
  • Mobile chat: a long chat with unread messages jumps to the unread tag

🤖 Generated with Claude Code

https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL

@changeset-bot

changeset-bot Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c32651a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Web shipped Avenir twice: harmony's avenir.css (~240 KB of base64, in the
entry CSS) and public/fonts.css (the same ~240 KB again). Every first visit
downloaded about 370 KB gzipped of font CSS, half of it render-blocking.

- public/fonts.css now points at six woff2 files in public/fonts/avenir
  (the same bytes, decoded) with font-display: swap, so browsers only fetch
  the weights a page renders.
- index.html preloads Medium (body), DemiBold and Bold (titles, headings,
  labels): about 90 KB.
- A Vite plugin drops harmony's inlined Avenir from the web build. Other
  harmony consumers are unchanged.
- The 2026 landing page's Dust Bucer hero font, which the hero waits on,
  loads as woff2 (284 KB otf -> 111 KB; outlines, kern and GPOS identical).

Entry CSS: 379 KB / 212 KB gzip -> 137 KB / 27 KB gzip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
- The playback rate/position hooks move out of WebPlayer into a
  PlaybackPersistence leaf, so play/pause, track changes and the 1s podcast
  position updates no longer re-render every route, the nav and modals.
- Position polling only runs while long-form content plays and selects just
  the long-form flag from the track. Position writes to localStorage are
  batched to one per 5s and flushed on pause, track change, pagehide and
  when the tab is hidden.
- Desktop PlayBar and NowPlaying only re-render when the displayed second
  changes, and prev/next read the position from the player, so the hotkeys
  are no longer re-bound every 500ms.
- The mobile-web PlayBar created a new interval after every render; the
  progress bar now owns its own interval and state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
PersistGate holds the whole app until redux-persist rehydrates. For any key
not yet in localStorage, persistStorage dynamic-imported localforage and
read IndexedDB to migrate it, which meant every first-time visitor waited on
an extra chunk download and an IndexedDB open before anything rendered.

Check indexedDB.databases() first: with no `localforage` database there is
nothing to migrate, so mark the key migrated and return. Browsers without
databases() keep the old path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
Lineups rebuilt every tile on each play/pause/skip because togglePlay
depended on the playing state and current queue item, and the lineup hooks
returned new trackIds arrays and loadNextPage functions on every render.

- Web and mobile TrackLineup (and mobile library TracksTab) read playback
  state from the store when a tile is pressed instead of subscribing to it.
  Tiles already select their own active/playing state.
- Lineup hooks memoize data and trackIds, and a new useLoadNextPage keeps
  loadNextPage stable between page loads, so the load-more
  IntersectionObserver isn't recreated on every render either.
- Mobile lineup SectionList uses windowSize 11 instead of the default 21.
- Library selectors use createSelector, so the library page no longer
  re-renders on every redux action; web useLibraryCollections builds its
  Set in useMemo.
- useToggleTrack (explore tiles, chat unfurls) selects one boolean instead
  of the whole current track; usePlayTrack and the collection page no
  longer create a selector or callback per render/track change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
- useCurrentAccount: placeholderData re-read and JSON-parsed the cached
  account from localStorage in every observer on every render while the
  account loaded (and for the whole session when signed out). The parsed
  result is now reused until the stored strings change.
- useTracks/useUsers/useCollections memoize their queries array, so
  tanstack doesn't re-hash and re-sync N observers on every render.
- Track page lineup: the remix parent/remixes, more-by-artist and trending
  requests run in parallel instead of one after another (3 round trips to 1).
  Section order and dedupe are unchanged.
- Track rank: trending IDs get a 30 minute staleTime instead of 5.
- Profile page: an artist's reposts are only fetched once the Reposts tab
  opens. Until the profile is known, reposts still load in parallel.
- Mobile: offline-downloads persist is throttled to 1s, and ChatScreen no
  longer renders every cached message on mount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
- AudioPlayer: starting playback ran makeTrackData over the entire queue,
  so a lineup or 1000-track playlist fired a HEAD request for every
  track's stream and 1000px artwork (about 2N requests), competing with
  the track that was buffering. Only the start track, the one before it
  and the next 2 are now checked against their mirrors; the rest, and
  appended tracks, go in with their primary URLs. If a primary URL fails
  when its track plays, the existing PlaybackError handler re-resolves it
  against the mirrors. Offline tracks still use local files and gated
  tracks are still signed. Queued tracks no longer reset the playing
  track's retry count.
- AudioPlayer: the offline-availability selector, which runs on every
  dispatch, builds its map in a loop instead of spreading in a reduce.
- useImageSize reads preloadImageFn from a ref, so callers passing an
  inline function (every mobile image) no longer re-run the resolve effect
  and prefetch again on each render. A cached larger image is only used up
  to 3.5x the requested width, so 150px thumbnails don't decode 1000px art.
- The collapsed Now Playing drawer loads 480px artwork and switches to
  1000px when opened. Feed collection tiles use 150px like track tiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmUrAQMioSmE4rsEgLPEQL
@dylanjeffers
dylanjeffers force-pushed the claude/clever-edison-u4bbxy branch from e651ec6 to c32651a Compare October 21, 2026 23:07
@dylanjeffers
dylanjeffers merged commit 282494a into main Oct 21, 2026
18 checks passed
@dylanjeffers
dylanjeffers deleted the claude/clever-edison-u4bbxy branch October 21, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants