Skip to content

feat(frontend): major bundle optimization and performance improvements - #19

Open
ryanbas21 wants to merge 10 commits into
Chaptarr:developfrom
ryanbas21:feat/frontend-bundle-optimization
Open

feat(frontend): major bundle optimization and performance improvements#19
ryanbas21 wants to merge 10 commits into
Chaptarr:developfrom
ryanbas21:feat/frontend-bundle-optimization

Conversation

@ryanbas21

@ryanbas21 ryanbas21 commented Aug 9, 2026

Copy link
Copy Markdown

Bundle size: 3.17MB → 3.39MB raw (+6.9%), 884KB → 948KB gzipped (+7.1%)
Initial load: 530KB → 339KB gzipped (−36.1% reduction)

Scope note: The jQuery→native-fetch and react-virtualized→react-window migrations
were originally included in this branch but introduced correctness bugs
(missing responseJSON compatibility, timeout-vs-AbortController mismatches).
They have been extracted to separate branches (save/jquery-ajax-to-fetch,
save/react-window-migration) for future evaluation and are not in this PR.
This PR retains the moment→dayjs migration, lodash cherry-pick imports,
webpack code-splitting, React performance fixes, and bug fixes.

Dependency migrations:

  • moment.js → dayjs (35 files, ~200KB saved)
  • lodash default imports → cherry-pick imports (131 files)

Bug fixes:

  • Register dayjs isBetween plugin (used by BookFileRow date range rendering)
  • Wrap booksImported Observable in values() to emit array contents instead of the array object
  • Wrap jQuery-Deferred return in Promise.resolve() for React Query compatibility
  • Scope service worker build to a dedicated webworker tsconfig with ambient process type declaration (typecheck green)

Webpack optimizations:

  • React.lazy + Suspense for all route components (37 routes). This could be a bit aggressive but i'm going to see how it feels.
  • splitChunks: vendor-react, vendors, asyncVendors, lodash cache groups
  • IgnorePlugin: moment locales, tough-cookie, psl, fetch-cookie
  • babel-plugin-lodash for tree-shaking

React performance fixes:

  • Fix Redux state mutation bug in createBooksClientSideCollectionSelector
  • Fix in-place array sort mutation in QueuedTaskRowNameCell
  • Add useMemo to TagList component
  • Add React.memo to 12 high-impact list/table components
  • Convert 9 class components to functional with hooks
  • react-query fetch caching

Build config:

  • Clean up redundant babel preset-env in webpack loader
  • Add loose:true to @babel/env production config
  • Add lodash cache group to splitChunks (priority 15)

Service Worker:

  • I waffled on this a bit whether or not to add this because they can add complexity.
  • ✅ Runtime caching — assets get cached as you browse (repeat visits are instant)
  • ✅ Navigation caching — the page shell loads from cache if the network is slow/down
  • ✅ API / SignalR / WebSocket traffic — passes straight through, never cached
  • ✅ Auto-updates — new deploys take over immediately (skipWaiting)

Some future looking ideas:

  • upgrade redux to toolkit version
  • further react query migration
  • react 18 + react query upgrade
  • clean up ui state

How was this tested?

Ran the app via docker, nothing seemed broken clicking through worked.

Screenshots (UI changes only)

image image image image

A note on AI: We know AI/agentic coding is everywhere and only getting
more popular. We won't insist that you disclose whether you used it or which
models you used, but in the same spirit, please don't take offense if your PR
is scrutinized and changes are requested.

Review time: The longer the PR and the more lines changed, the longer the
review will take. Small, focused PRs merge fastest. If yours is big, please be
patient.

@ryanbas21
ryanbas21 marked this pull request as ready for review August 10, 2026 14:48
@robertlordhood

Copy link
Copy Markdown
Contributor

Thanks for this! A lot of good stuff in here, but I'm not comfy merging it all as one PR. Would you be willing to split it into smaller PRs so I can test each change? Thanks!

@nickwolf

Copy link
Copy Markdown
Contributor

I'm really intrigued by this PR, but it's just sooooooooo huge. Even if I was to just farm it out to Claude or something, it would burn my entire usage window.

@ryanbas21

Copy link
Copy Markdown
Author

I'm really intrigued by this PR, but it's just sooooooooo huge. Even if I was to just farm it out to Claude or something, it would burn my entire usage window.

Thanks for this! A lot of good stuff in here, but I'm not comfy merging it all as one PR. Would you be willing to split it into smaller PRs so I can test each change? Thanks!

I will say most of the changes are the lodash file renames. but I can look to change it up a bit

@ryanbas21
ryanbas21 force-pushed the feat/frontend-bundle-optimization branch from 15aaa97 to 750c20d Compare August 11, 2026 02:45
@ryanbas21

ryanbas21 commented Aug 11, 2026

Copy link
Copy Markdown
Author

@robertlordhood @nickwolf I broke them out into individual commits (agent did yay).

Let me know if this makes it more reviewable. I understand they build on each other, and it's still a lot of files.

If you get past the lodash renaming then you basically cut this in half and hopefully individual commits helps.

If not, I can break these into separate PR's.

@ryanbas21
ryanbas21 force-pushed the feat/frontend-bundle-optimization branch from 750c20d to a756dd6 Compare August 11, 2026 02:54
@nickwolf

Copy link
Copy Markdown
Contributor

I went ahead and burned the usage window on it. The individual commits did help, so thanks for that. Two things came out of it: the lodash framing holds up by file count but not by lines, and there are four bugs worth fixing before this merges.

On the file count you're right. 120 of the 245 files are touched only by the lodash commit, so "cut this in half" is fair as far as that goes. By lines it's much smaller. That commit is 965 lines across frontend/src, plus 15 in babel.config.js and webpack.config.js, out of 8,080 non-lockfile lines in the PR. So the renames are about 12% of the reviewable diff, not half. The 10,500 line total is also inflated by 2,420 lines of yarn.lock, and 2,381 of those sit inside the lodash commit, which is most of why it looks so big.

The review time is really in the jQuery to fetch commit (2,765 lines in frontend/src) and react-virtualized to react-window (2,411). Those two are 65% of the source churn and neither is renaming. The commit boundaries also don't match the commit messages, which is what made this hard to follow: QueueRow.js (873 lines), HistoryRow.js (410), BlocklistRow.js (315), AuthorEditorRow.js (225) and IgnoredDownloadRow.js (178) are class to functional component conversions sitting inside the commit titled "replace jQuery AJAX with native fetch", and the new 123-line sw.js is inside the one titled "add React Query for server state management".

Four bugs, all in the parts I actually read:

  1. dayjs is missing the isBetween plugin. dayjsSetup.js extends relativeTime, duration, weekOfYear, isoWeek and customParseFormat, and isInNextWeek.js line 8 calls dayjs(date).isBetween(...). moment had isBetween built in, dayjs needs the plugin, and nothing else registers it transitively (I checked against 1.11.21 with exactly those five plugins). The throw happens on the call, not on the range test, so getRelativeDate.js line 36 blows up for every date that isn't yesterday, today or tomorrow, past dates included. That's 9 consumers including RelativeDateCell, TimeleftCell and DayOfWeek, and ShowRelativeDates defaults to true in ConfigService.cs line 518, so it's on unless someone turned it off. The rest of the dayjs plugin surface looks fine, .from, .fromNow and .duration are all covered.

  2. createAjaxRequest never populates responseJSON. It sets responseText, status, statusText and aborted, and there are about 50 reads of xhr.responseJSON left across 22 files. Five other sites build a responseJSON themselves and still work, so this only affects errors coming back from a real request. No crash, every deref is behind an optional chain or a truthiness check, but getErrorMessage always returns its fallback string, and at SpinnerErrorButton.js line 26 a 400 gives some(undefined, ...) twice, so hasWarning and hasError both come out false and the button shows no error state at all rather than the validation failures.

  3. A timeout is indistinguishable from a user abort. The timeout calls abortRequest() on the same AbortController, fetch rejects with an AbortError, and line 154 sets aborted: true on it. 13 call sites then do error: xhr.aborted ? null : xhr and discard it. createFetchHandler.js is the clearest one: it dispatches isFetching false, isPopulated false and error null together, so the section renders an empty state with no error and no retry rather than surfacing the timeout. Only AudioBookShelfLibraryMappings.js line 241 passes a timeout today (10 seconds), so it's narrow now, but it's a trap for the next one.

  4. There's a semantic change inside the lodash commit. InteractiveImportModalContent.js line 220 rewrote _(items).filter(...).keyBy((x) => x.book.id).map((x) => x.book).value() as mapValues(keyBy(...), (x) => x.book). Lodash map over a keyed object returns an array, mapValues returns an object, so booksImported changes from array to object. It initializes to [] at line 154 and goes to ConfirmImportModalContent at line 623, whose propType at line 126 is arrayOf, so that's a propType warning. It still works because the consumer uses lodash find, which takes objects. Six of the seven chain rewrites in that commit wrap in values() correctly and this one doesn't, which is why I don't think that commit can be reviewed as renames.

Two smaller ones: jquery 3.7.1 is still in package.json line 58 with no imports left in frontend/src, and the sw.js comment at line 59 flags sub-path urlBase deployments as unhandled and needing live-server testing, so that's worth pinning down before it ships, though subdomain installs at the root are unaffected.

What I did not look at: the react-window migration, the 37 lazy routes and the react-query commit, which is 3,791 lines between them. Treat it as unreviewed. It's also the part most likely to change behaviour in ways that clicking through wouldn't catch. The Build Chaptarr workflow has also been sitting at action_required on both pushes, so someone needs to approve the run before CI has said anything either way.

Not my repo and not my call, but if jQuery to fetch and react-window went out as their own PRs with the row conversions kept separate, I'd happily review both properly. Is there a reason the two need to land together?

@ryanbas21

ryanbas21 commented Aug 11, 2026

Copy link
Copy Markdown
Author

I went ahead and burned the usage window on it. The individual commits did help, so thanks for that. Two things came out of it: the lodash framing holds up by file count but not by lines, and there are four bugs worth fixing before this merges.

On the file count you're right. 120 of the 245 files are touched only by the lodash commit, so "cut this in half" is fair as far as that goes. By lines it's much smaller. That commit is 965 lines across frontend/src, plus 15 in babel.config.js and webpack.config.js, out of 8,080 non-lockfile lines in the PR. So the renames are about 12% of the reviewable diff, not half. The 10,500 line total is also inflated by 2,420 lines of yarn.lock, and 2,381 of those sit inside the lodash commit, which is most of why it looks so big.

The review time is really in the jQuery to fetch commit (2,765 lines in frontend/src) and react-virtualized to react-window (2,411). Those two are 65% of the source churn and neither is renaming. The commit boundaries also don't match the commit messages, which is what made this hard to follow: QueueRow.js (873 lines), HistoryRow.js (410), BlocklistRow.js (315), AuthorEditorRow.js (225) and IgnoredDownloadRow.js (178) are class to functional component conversions sitting inside the commit titled "replace jQuery AJAX with native fetch", and the new 123-line sw.js is inside the one titled "add React Query for server state management".

Four bugs, all in the parts I actually read:

  1. dayjs is missing the isBetween plugin. dayjsSetup.js extends relativeTime, duration, weekOfYear, isoWeek and customParseFormat, and isInNextWeek.js line 8 calls dayjs(date).isBetween(...). moment had isBetween built in, dayjs needs the plugin, and nothing else registers it transitively (I checked against 1.11.21 with exactly those five plugins). The throw happens on the call, not on the range test, so getRelativeDate.js line 36 blows up for every date that isn't yesterday, today or tomorrow, past dates included. That's 9 consumers including RelativeDateCell, TimeleftCell and DayOfWeek, and ShowRelativeDates defaults to true in ConfigService.cs line 518, so it's on unless someone turned it off. The rest of the dayjs plugin surface looks fine, .from, .fromNow and .duration are all covered.
  2. createAjaxRequest never populates responseJSON. It sets responseText, status, statusText and aborted, and there are about 50 reads of xhr.responseJSON left across 22 files. Five other sites build a responseJSON themselves and still work, so this only affects errors coming back from a real request. No crash, every deref is behind an optional chain or a truthiness check, but getErrorMessage always returns its fallback string, and at SpinnerErrorButton.js line 26 a 400 gives some(undefined, ...) twice, so hasWarning and hasError both come out false and the button shows no error state at all rather than the validation failures.
  3. A timeout is indistinguishable from a user abort. The timeout calls abortRequest() on the same AbortController, fetch rejects with an AbortError, and line 154 sets aborted: true on it. 13 call sites then do error: xhr.aborted ? null : xhr and discard it. createFetchHandler.js is the clearest one: it dispatches isFetching false, isPopulated false and error null together, so the section renders an empty state with no error and no retry rather than surfacing the timeout. Only AudioBookShelfLibraryMappings.js line 241 passes a timeout today (10 seconds), so it's narrow now, but it's a trap for the next one.
  4. There's a semantic change inside the lodash commit. InteractiveImportModalContent.js line 220 rewrote _(items).filter(...).keyBy((x) => x.book.id).map((x) => x.book).value() as mapValues(keyBy(...), (x) => x.book). Lodash map over a keyed object returns an array, mapValues returns an object, so booksImported changes from array to object. It initializes to [] at line 154 and goes to ConfirmImportModalContent at line 623, whose propType at line 126 is arrayOf, so that's a propType warning. It still works because the consumer uses lodash find, which takes objects. Six of the seven chain rewrites in that commit wrap in values() correctly and this one doesn't, which is why I don't think that commit can be reviewed as renames.

Two smaller ones: jquery 3.7.1 is still in package.json line 58 with no imports left in frontend/src, and the sw.js comment at line 59 flags sub-path urlBase deployments as unhandled and needing live-server testing, so that's worth pinning down before it ships, though subdomain installs at the root are unaffected.

What I did not look at: the react-window migration, the 37 lazy routes and the react-query commit, which is 3,791 lines between them. Treat it as unreviewed. It's also the part most likely to change behaviour in ways that clicking through wouldn't catch. The Build Chaptarr workflow has also been sitting at action_required on both pushes, so someone needs to approve the run before CI has said anything either way.

Not my repo and not my call, but if jQuery to fetch and react-window went out as their own PRs with the row conversions kept separate, I'd happily review both properly. Is there a reason the two need to land together?

I can drop the jquery and the react window commits and make a separate pr for them. will do later tonight.

…ry Deferred compat

React Query commit used .catch() on createAjaxRequest().request which is a
jQuery Deferred on the develop base (no .catch method). Wrapping with
Promise.resolve assimilates the Deferred into a native Promise so .catch works.
moment is replaced by dayjs; drop the now-unused moment dependency.
react-virtualized and jquery are retained.
@ryanbas21
ryanbas21 force-pushed the feat/frontend-bundle-optimization branch from a756dd6 to 81f8bc2 Compare August 12, 2026 04:28
@ryanbas21

ryanbas21 commented Aug 12, 2026

Copy link
Copy Markdown
Author

Alrighty - sorry for the files i got it down dropped those commits and fixed the tsc issues and the dayjs issue :)

Note: I'm happy to squash these commits whenever this is ready obviously leaving the commits as such now for reviews and to not ruin history.

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.

3 participants